Replace the yt-dlp subprocess with the pure-Rust rustypipe client
Playback of YouTube tracks was broken: bestaudio selects WebM/Opus and the rodio+symphonia player has no Opus decoder. The new extractor picks the highest-bitrate audio/mp4 (AAC) stream instead, which decodes — and captures get playable .m4a files. rusty_ytdl, rustube, and rust-yt-downloader were evaluated live and rejected (broken or stale); rustypipe works end to end and is actively maintained. Provider logic now tests against a fake Extract seam, login keeps the cookies.txt setting with rustypipe caching the rotated cookie, saved playlists replace the unvalidated feed scrape, and yt-dlp leaves devenv. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
214dece19f
commit
6f787285bf
|
|
@ -2,6 +2,12 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -11,6 +17,21 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
|
|
@ -143,6 +164,18 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-executor"
|
name = "async-executor"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
|
|
@ -363,7 +396,16 @@ version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec",
|
"bit-vec 0.6.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-set"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||||
|
dependencies = [
|
||||||
|
"bit-vec 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -372,6 +414,12 @@ version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
|
|
@ -415,6 +463,36 @@ dependencies = [
|
||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bs58"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
|
|
@ -530,6 +608,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -634,6 +713,24 @@ dependencies = [
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||||
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -762,7 +859,7 @@ dependencies = [
|
||||||
"fsdy",
|
"fsdy",
|
||||||
"futures",
|
"futures",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"reqwest",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
|
|
@ -777,6 +874,15 @@ dependencies = [
|
||||||
"ytdy",
|
"ytdy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "critical-section"
|
name = "critical-section"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -911,6 +1017,9 @@ name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
|
|
@ -1145,10 +1254,21 @@ version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set 0.5.3",
|
||||||
"regex",
|
"regex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fancy-regex"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
|
||||||
|
dependencies = [
|
||||||
|
"bit-set 0.8.0",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fast-srgb8"
|
name = "fast-srgb8"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -1199,6 +1319,16 @@ version = "0.5.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
|
|
@ -1605,6 +1735,7 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1984,6 +2115,16 @@ version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "localzone"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "718530aa7b3842752f82224791ea1db5e6b7260513b8ab45706937dbf54f7b4d"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"windows 0.54.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
|
|
@ -2101,6 +2242,16 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -3090,6 +3241,44 @@ version = "0.8.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
|
|
@ -3141,6 +3330,16 @@ version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ress"
|
||||||
|
version = "0.11.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d77111a94ef694bb2fe9867a93e21007e2151943c9a48b2ed72734bde41875a"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"unicode-xid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -3168,6 +3367,33 @@ dependencies = [
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rquickjs"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c5227859c4dfc83f428e58f9569bf439e628c8d139020e7faff437e6f5abaa0"
|
||||||
|
dependencies = [
|
||||||
|
"rquickjs-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rquickjs-core"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e82e0ca83028ad5b533b53b96c395bbaab905a5774de4aaf1004eeacafa3d85d"
|
||||||
|
dependencies = [
|
||||||
|
"rquickjs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rquickjs-sys"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fed0097b0b4fbb2a87f6dd3b995a7c64ca56de30007eb7e867dfdfc78324ba5"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
|
|
@ -3204,6 +3430,7 @@ checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
@ -3277,6 +3504,35 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustypipe"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3bf8d7c42ac7860a078f63f90d6513dd3833585337e65b5fca9f74ddba2d59ef"
|
||||||
|
dependencies = [
|
||||||
|
"data-encoding",
|
||||||
|
"fancy-regex 0.14.0",
|
||||||
|
"localzone",
|
||||||
|
"once_cell",
|
||||||
|
"phf",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"regex",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"ress",
|
||||||
|
"rquickjs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_plain",
|
||||||
|
"serde_with",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
@ -3379,6 +3635,15 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_plain"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.21"
|
version = "0.1.21"
|
||||||
|
|
@ -3411,6 +3676,45 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_with"
|
||||||
|
version = "3.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bs58",
|
||||||
|
"chrono",
|
||||||
|
"hex",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with_macros",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_with_macros"
|
||||||
|
version = "3.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
|
|
@ -3468,6 +3772,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|
@ -3529,7 +3839,7 @@ dependencies = [
|
||||||
"mediatype",
|
"mediatype",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rangemap",
|
"rangemap",
|
||||||
"reqwest",
|
"reqwest 0.13.1",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -3897,7 +4207,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"fancy-regex",
|
"fancy-regex 0.11.0",
|
||||||
"filedescriptor",
|
"filedescriptor",
|
||||||
"finl_unicode",
|
"finl_unicode",
|
||||||
"fixedbitset 0.4.2",
|
"fixedbitset 0.4.2",
|
||||||
|
|
@ -3988,7 +4298,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"flume",
|
"flume",
|
||||||
"reqwest",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
|
@ -4262,12 +4572,17 @@ version = "0.6.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -4292,6 +4607,7 @@ version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|
@ -4424,6 +4740,12 @@ 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 = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -4442,6 +4764,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -4737,6 +5065,16 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.54.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
|
|
@ -4780,6 +5118,16 @@ dependencies = [
|
||||||
"windows-core 0.62.2",
|
"windows-core 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result 0.1.2",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|
@ -4893,6 +5241,15 @@ dependencies = [
|
||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|
@ -5154,8 +5511,9 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
|
"dirs",
|
||||||
|
"rustypipe",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ rodio = { version = "0.22", default-features = false, features = [
|
||||||
"playback",
|
"playback",
|
||||||
"symphonia-all",
|
"symphonia-all",
|
||||||
] }
|
] }
|
||||||
|
rustypipe = { version = "0.11", default-features = false, features = [
|
||||||
|
"rustls-tls-webpki-roots",
|
||||||
|
"userdata",
|
||||||
|
] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
# YouTube provider (ytdy)
|
# YouTube provider (ytdy)
|
||||||
|
|
||||||
|
> **Extraction engine superseded** by `youtube-rustypipe.md`: the
|
||||||
|
> `yt-dlp` subprocess (D1, D5, the `binary` setting, and the
|
||||||
|
> `bestaudio` format choice) was replaced with the pure-Rust
|
||||||
|
> `rustypipe` client after playback turned out broken (bestaudio =
|
||||||
|
> Opus, which the rodio/symphonia player cannot decode) and the Python
|
||||||
|
> subprocess proved unwanted. The tree shape, search-term store, path
|
||||||
|
> scheme, and optional-login gating described here still hold.
|
||||||
|
|
||||||
## Context and problem statement
|
## Context and problem statement
|
||||||
|
|
||||||
A new library provider for YouTube, mounted at `/youtube`:
|
A new library provider for YouTube, mounted at `/youtube`:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
# YouTube provider on rustypipe (pure Rust)
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
The first `ytdy` iteration shelled out to `yt-dlp` (a Python tool). Two
|
||||||
|
problems surfaced in real use:
|
||||||
|
|
||||||
|
1. **Playback was broken.** Search worked, but picked tracks never
|
||||||
|
played: `yt-dlp -f bestaudio` selects WebM/**Opus**, and the player is
|
||||||
|
rodio + symphonia (`symphonia-all`) — symphonia has **no Opus
|
||||||
|
decoder**. The stream downloaded fine and then failed to decode.
|
||||||
|
(Download captures of YouTube tracks had the same latent problem:
|
||||||
|
`.webm` files that the local player cannot decode.)
|
||||||
|
2. The user does not want a Python subprocess in the loop.
|
||||||
|
|
||||||
|
## Evaluation of pure-Rust extractors (2026-07-21, live-tested)
|
||||||
|
|
||||||
|
- `rusty_ytdl` 0.7.4 — search works, but **stream URLs come back
|
||||||
|
empty** and `stream()` fails with "Video source empty": its cipher
|
||||||
|
handling has fallen behind YouTube's rotation (last release early
|
||||||
|
2025).
|
||||||
|
- `rustube` 0.6.0 — unmaintained since ~2022; not tested further.
|
||||||
|
- `rust-yt-downloader` 0.1.0 — a small CLI, not a library engine.
|
||||||
|
- `rustypipe` 0.11.4 — **works end to end**: search, video details,
|
||||||
|
playlists, and `player()` returns deciphered stream URLs (verified:
|
||||||
|
HTTP 206 fetch, and the full itag-140 m4a of a test video decodes
|
||||||
|
through `rodio::Decoder` — 44.1 kHz samples out). Actively maintained
|
||||||
|
(NewPipe-inspired Innertube client).
|
||||||
|
|
||||||
|
**Decision: replace the subprocess engine with `rustypipe`.** It removes
|
||||||
|
the Python dependency *and* the format problem: we pick the stream
|
||||||
|
ourselves and prefer `audio/mp4` (AAC — symphonia decodes it) over
|
||||||
|
Opus. Captures get `.m4a` audio the local player can play.
|
||||||
|
|
||||||
|
Robustness note: any non-yt-dlp extractor can break when YouTube
|
||||||
|
changes Innertube. rustypipe is the most actively maintained Rust
|
||||||
|
option, ships `rustypipe-botguard` (an optional *Rust* helper binary,
|
||||||
|
auto-detected on PATH) for PO-token attestation if YouTube starts
|
||||||
|
demanding it, and persists client state in a cache file. Accepted risk,
|
||||||
|
revisit if streams start failing.
|
||||||
|
|
||||||
|
## D1 — Extractor seam
|
||||||
|
|
||||||
|
The subprocess `Engine` is replaced by an `Extractor` trait owned by
|
||||||
|
`ytdy` (search videos, video details, audio stream URL, saved
|
||||||
|
playlists, playlist videos) with two implementations:
|
||||||
|
|
||||||
|
- `RustyPipeExtractor` — the real one, wrapping one `RustyPipe` client.
|
||||||
|
- a test fake — provider logic (path scheme, search-term store, node
|
||||||
|
shapes, gating) is tested without network or fake shell scripts.
|
||||||
|
|
||||||
|
`Client` keeps its public `ProviderClient` surface, path scheme, and
|
||||||
|
the in-memory search-term store unchanged.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
tui -> server -> ytdy: "/youtube/..."
|
||||||
|
ytdy: {
|
||||||
|
client: "Client\n(paths, search terms, nodes)"
|
||||||
|
extractor: "Extractor trait"
|
||||||
|
client -> extractor
|
||||||
|
}
|
||||||
|
ytdy.extractor -> rustypipe: "RustyPipeExtractor"
|
||||||
|
rustypipe -> youtube: "Innertube (HTTPS)"
|
||||||
|
tests -> ytdy.extractor: "FakeExtractor"
|
||||||
|
```
|
||||||
|
|
||||||
|
## D2 — Stream selection (the playback fix)
|
||||||
|
|
||||||
|
`audio_stream_url` prefers the highest-average-bitrate `audio/mp4`
|
||||||
|
stream (AAC — decodable by the player); only if none exists does it
|
||||||
|
fall back to the overall best audio stream, with a warning (it will
|
||||||
|
likely not decode locally, but the URL is still honest — e.g. a future
|
||||||
|
player may cope). Download captures inherit the same choice, so their
|
||||||
|
audio is `.m4a`.
|
||||||
|
|
||||||
|
## D3 — Login and playlists
|
||||||
|
|
||||||
|
The `cookies` setting keeps its meaning: a path to a Netscape
|
||||||
|
`cookies.txt` export. rustypipe consumes it natively
|
||||||
|
(`user_auth_set_cookie_txt`) and — importantly — **persists the rotated
|
||||||
|
cookie in its cache file**, which outlives the (quickly stale) original
|
||||||
|
export. Init order: if the cache already holds a working login
|
||||||
|
(`user_auth_check_cookie`), use it; otherwise load the configured file;
|
||||||
|
on any failure degrade to logged-out with a warning (never a failed
|
||||||
|
init). "Logged in" gates the `playlists` subtree exactly as before,
|
||||||
|
now backed by `saved_playlists()` (the `userdata` feature) instead of a
|
||||||
|
live-unvalidated `feed/playlists` scrape.
|
||||||
|
|
||||||
|
Playlist nodes page through `Paginator::extend_limit` up to a track
|
||||||
|
cap (`MAX_PLAYLIST_TRACKS`, 1000) instead of loading whole playlists
|
||||||
|
blindly.
|
||||||
|
|
||||||
|
## D4 — Configuration and environment
|
||||||
|
|
||||||
|
`ytdy.toml`: `binary` is gone (nothing to spawn); `cookies` and
|
||||||
|
`search_results` stay; `call_timeout_secs` maps to the rustypipe
|
||||||
|
client timeout. New `RustyPipe` client state lives in
|
||||||
|
`<config>/crabidy/rustypipe/` (`storage_dir`) — it holds the rotated
|
||||||
|
auth cookie, so it is as secret as the cookies file; neither its
|
||||||
|
contents nor cookie values are ever logged. `yt-dlp` leaves
|
||||||
|
`devenv.nix`. Init never probes the network except when validating a
|
||||||
|
configured login; a failed login check degrades, a broken client build
|
||||||
|
disables the provider non-fatally (as before).
|
||||||
|
|
||||||
|
## D5 — Out of scope
|
||||||
|
|
||||||
|
- Installing `rustypipe-botguard` (optional PO-token helper); document
|
||||||
|
only. Streams work without it today.
|
||||||
|
- Opus support in the player (a symphonia Opus decoder does not exist;
|
||||||
|
an opus feature via a different rodio decoder is a separate project).
|
||||||
|
- YouTube Music (rustypipe supports it; nothing in crabidy asks yet).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Innertube changes can break rustypipe between releases; mitigations:
|
||||||
|
cache-backed client data, optional botguard, active upstream.
|
||||||
|
- `saved_playlists` needs valid cookies; YouTube rotates them — the
|
||||||
|
cache keeps the rotated value, but a long-cold server may need a
|
||||||
|
fresh export. Degrades to logged-out, never fails.
|
||||||
|
|
@ -14,8 +14,6 @@ let
|
||||||
d2
|
d2
|
||||||
pkg-config
|
pkg-config
|
||||||
protobuf
|
protobuf
|
||||||
# Extraction engine for the ytdy provider (architecture/youtube-provider.md D1).
|
|
||||||
yt-dlp
|
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,43 @@
|
||||||
# Implementation summaries
|
# Implementation summaries
|
||||||
|
|
||||||
|
## youtube-rustypipe (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/youtube-rustypipe.md`: the ytdy provider's `yt-dlp`
|
||||||
|
subprocess engine was replaced with the pure-Rust **rustypipe**
|
||||||
|
Innertube client, fixing broken playback along the way.
|
||||||
|
|
||||||
|
Root cause of "search works but nothing plays": `-f bestaudio` selects
|
||||||
|
WebM/**Opus**, and the player (rodio + symphonia) has no Opus decoder.
|
||||||
|
The new engine picks the highest-bitrate `audio/mp4` (AAC) stream,
|
||||||
|
which symphonia decodes — verified live end to end (rustypipe stream
|
||||||
|
URL → download → `rodio::Decoder` produces samples). Download captures
|
||||||
|
of YouTube tracks now get playable `.m4a` files too.
|
||||||
|
|
||||||
|
The alternatives the user suggested were live-tested first:
|
||||||
|
`rusty_ytdl` 0.7.4 searches fine but returns empty stream URLs (cipher
|
||||||
|
rotation outran it), `rustube` is unmaintained since ~2022,
|
||||||
|
`rust-yt-downloader` is a thin CLI. `rustypipe` 0.11.4 worked for
|
||||||
|
everything (see `architecture/youtube-rustypipe.md`).
|
||||||
|
|
||||||
|
Design: an `Extract` trait seam (search, video, audio stream URL,
|
||||||
|
saved playlists, playlist videos) with `RustyPipeExtractor` as the
|
||||||
|
real implementation — provider logic is tested against a programmable
|
||||||
|
fake (no network, no fake shell scripts). Login keeps the `cookies`
|
||||||
|
setting (Netscape export) via `user_auth_set_cookie_txt`, cache-first:
|
||||||
|
rustypipe refreshes and persists the rotated cookie under
|
||||||
|
`<config>/crabidy/rustypipe/`, so it outlives the stale export; any
|
||||||
|
login failure degrades to logged-out. Saved playlists replace the
|
||||||
|
never-validated `feed/playlists` scrape; playlist nodes page up to
|
||||||
|
1000 tracks. `ytdy.toml` loses `binary` (old keys tolerated),
|
||||||
|
`yt-dlp` left `devenv.nix`, ytdy no longer needs `tokio/process`.
|
||||||
|
|
||||||
|
Deviations: none from the new architecture doc; the original
|
||||||
|
`youtube-provider.md` engine decision (D1) is marked superseded.
|
||||||
|
Live probe (search → mp4 stream URL → 206 fetch → metadata) ran
|
||||||
|
against real YouTube and was removed after passing. 169 workspace
|
||||||
|
tests green (ytdy: 10 + 2 extractor tests, all offline); every gate in
|
||||||
|
`quality/youtube-rustypipe.md` checked.
|
||||||
|
|
||||||
## incremental-captures (2026-07-21)
|
## incremental-captures (2026-07-21)
|
||||||
|
|
||||||
Built per `plan/incremental-captures.md`: download captures are now
|
Built per `plan/incremental-captures.md`: download captures are now
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Plan: youtube-rustypipe
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (ytdy tests and/or gates in
|
||||||
|
`quality/youtube-rustypipe.md`).
|
||||||
|
|
||||||
|
- [x] **T1 — Extractor trait + models.** `Extractor` (search_videos,
|
||||||
|
video, audio_stream_url, saved_playlists, playlist_videos) with
|
||||||
|
`VideoEntry`/`PlaylistEntry` models and a typed `ExtractError`;
|
||||||
|
`Client` holds a `Box<dyn Extractor>`. Verifies: gates "Extractor
|
||||||
|
seam".
|
||||||
|
- [x] **T2 — RustyPipeExtractor.** rustypipe client (rustls, storage
|
||||||
|
dir, timeout, retries), mp4a-preferring stream pick, cookie-txt
|
||||||
|
login with cache-first order, saved playlists + bounded playlist
|
||||||
|
pagination. Verifies: gates "Stream selection", "Login"; live probe.
|
||||||
|
- [x] **T3 — Client rework.** Settings without `binary`; init builds
|
||||||
|
the extractor non-fatally; logged-in gating from the login outcome;
|
||||||
|
all trait methods routed through the seam. Verifies: existing
|
||||||
|
behavioral tests, rewritten against the fake.
|
||||||
|
- [x] **T4 — Tests.** Port every ytdy test from fake-script to
|
||||||
|
FakeExtractor; add stream-preference and login-degradation tests.
|
||||||
|
Verifies: ytdy suite green without network.
|
||||||
|
- [x] **T5 — Environment.** Remove `yt-dlp` from `devenv.nix`; trim
|
||||||
|
ytdy `Cargo.toml` (drop process/io-util, add rustypipe); root
|
||||||
|
workspace dep. Verifies: gates "Environment".
|
||||||
|
- [x] **T6 — Full verification + live probe.** Workspace suite,
|
||||||
|
clippy/fmt/markdownlint; temporary ignored probe: search, stream
|
||||||
|
URL fetch, decode, download capture — removed after passing.
|
||||||
|
- [x] **T7 — Docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile the architecture docs.
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Quality gates: youtube-rustypipe
|
||||||
|
|
||||||
|
Criteria beyond the automatic tests (ytdy unit tests run against the
|
||||||
|
fake extractor; live behavior verified by a removed-after-passing
|
||||||
|
probe).
|
||||||
|
|
||||||
|
## Extractor seam (D1)
|
||||||
|
|
||||||
|
- [x] All provider logic (paths, search-term store, node shapes,
|
||||||
|
capability flags, error mapping) is testable through the `Extractor`
|
||||||
|
trait with a fake — no network, no fake shell scripts, no yt-dlp.
|
||||||
|
- [x] The `ProviderClient` surface, path scheme, and search-term
|
||||||
|
semantics (dedup, implicit recreation, rename re-searches, idempotent
|
||||||
|
delete) are unchanged — existing behavioral tests carry over.
|
||||||
|
|
||||||
|
## Stream selection (D2)
|
||||||
|
|
||||||
|
- [x] `get_urls_for_track` returns the highest-average-bitrate
|
||||||
|
`audio/mp4` stream; a non-mp4 fallback logs a warning naming the
|
||||||
|
chosen mime.
|
||||||
|
- [x] No panic when a video has no audio streams — typed error.
|
||||||
|
|
||||||
|
## Login (D3)
|
||||||
|
|
||||||
|
- [x] A cached valid login wins over re-reading the (stale-prone)
|
||||||
|
cookies file; a missing/unreadable/rejected cookies file degrades to
|
||||||
|
logged-out with a warning — init never fails over login.
|
||||||
|
- [x] Cookie values, auth headers, and rustypipe cache contents never
|
||||||
|
appear in logs or errors (paths only).
|
||||||
|
- [x] `playlists` shows up only when logged in; playlist nodes cap
|
||||||
|
their track count (`MAX_PLAYLIST_TRACKS`) via bounded pagination.
|
||||||
|
|
||||||
|
## Environment (D4)
|
||||||
|
|
||||||
|
- [x] `yt-dlp` is gone from `devenv.nix`; ytdy no longer depends on
|
||||||
|
`tokio/process`; the `binary` setting is gone from `ytdy.toml`
|
||||||
|
(unknown old keys are tolerated).
|
||||||
|
- [x] rustypipe state lives under `<config>/crabidy/rustypipe/`; a
|
||||||
|
broken client build disables the provider non-fatally.
|
||||||
|
- [x] rustypipe uses rustls (no openssl build dependency).
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New/changed public items documented, stating error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `markdownlint` clean; all tests
|
||||||
|
green.
|
||||||
|
- [x] Live probe (removed after passing): search + stream URL +
|
||||||
|
download capture through the real provider; stream decodes with the
|
||||||
|
player's decoder stack.
|
||||||
|
|
@ -6,16 +6,10 @@ edition.workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
rustypipe.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = [
|
|
||||||
"time",
|
|
||||||
"sync",
|
|
||||||
"macros",
|
|
||||||
"process",
|
|
||||||
"io-util",
|
|
||||||
] }
|
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
//! The yt-dlp subprocess seam (see `architecture/youtube-provider.md` D1/D5).
|
|
||||||
//!
|
|
||||||
//! Every YouTube interaction is one bounded `yt-dlp` invocation: argument
|
|
||||||
//! list only (never a shell), `--no-warnings`, an optional `--cookies`
|
|
||||||
//! file, a per-call timeout, and a cap on captured stdout. JSON output is
|
|
||||||
//! parsed with `serde_json`; failures are typed [`EngineError`]s — no
|
|
||||||
//! subprocess condition may panic. Tests point `binary` at a fake script.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
/// Captured stdout beyond this many bytes aborts the call — a runaway
|
|
||||||
/// extractor must not balloon memory.
|
|
||||||
pub const MAX_STDOUT_BYTES: usize = 32 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Errors from one engine invocation.
|
|
||||||
///
|
|
||||||
/// Messages carry the binary name, exit status, and a short stderr
|
|
||||||
/// summary — never full stderr (it can embed URLs) and never cookie
|
|
||||||
/// contents.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum EngineError {
|
|
||||||
#[error("cannot run yt-dlp: {0}")]
|
|
||||||
Spawn(#[from] std::io::Error),
|
|
||||||
#[error("yt-dlp timed out after {0:?}")]
|
|
||||||
Timeout(Duration),
|
|
||||||
#[error("yt-dlp failed ({status}): {summary}")]
|
|
||||||
Failed { status: String, summary: String },
|
|
||||||
#[error("cannot parse yt-dlp output: {0}")]
|
|
||||||
Parse(#[from] serde_json::Error),
|
|
||||||
#[error("yt-dlp output exceeded {MAX_STDOUT_BYTES} bytes")]
|
|
||||||
Oversized,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One entry of a `--flat-playlist -J` listing (or a single-video `-J`).
|
|
||||||
/// Fields we don't use are ignored; absent fields stay `None` — flat
|
|
||||||
/// extraction fills what it cheaply can.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct Entry {
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
/// Real yt-dlp output carries `uploader` *and* `channel` (so a serde
|
|
||||||
/// alias would reject it as a duplicate field); both are kept and
|
|
||||||
/// [`Self::artist`] picks.
|
|
||||||
#[serde(default)]
|
|
||||||
pub uploader: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub channel: Option<String>,
|
|
||||||
/// Seconds; yt-dlp emits floats.
|
|
||||||
#[serde(default)]
|
|
||||||
pub duration: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Entry {
|
|
||||||
/// The track artist: the uploader, falling back to the channel.
|
|
||||||
pub fn artist(&self) -> Option<&str> {
|
|
||||||
self.uploader.as_deref().or(self.channel.as_deref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `-J` playlist-shaped result: its `entries`, plus the playlist title.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Listing {
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub entries: Vec<Entry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shared subprocess runner. Cheap to clone-by-reference (the client
|
|
||||||
/// holds one); all state is configuration.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Engine {
|
|
||||||
binary: PathBuf,
|
|
||||||
cookies: Option<PathBuf>,
|
|
||||||
timeout: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Engine {
|
|
||||||
/// An engine invoking `binary` with `timeout` per call, passing
|
|
||||||
/// `--cookies <cookies>` on every call when set.
|
|
||||||
pub fn new(binary: PathBuf, cookies: Option<PathBuf>, timeout: Duration) -> Self {
|
|
||||||
Self {
|
|
||||||
binary,
|
|
||||||
cookies,
|
|
||||||
timeout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `yt-dlp --version` — the init probe. The trimmed version string on
|
|
||||||
/// success.
|
|
||||||
pub async fn probe(&self) -> Result<String, EngineError> {
|
|
||||||
let stdout = self.run(&["--version"]).await?;
|
|
||||||
Ok(String::from_utf8_lossy(&stdout).trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A flat listing (`-J --flat-playlist`) of `target` — a
|
|
||||||
/// `ytsearchN:` term, a playlist URL, or the playlists feed.
|
|
||||||
pub async fn flat_listing(&self, target: &str) -> Result<Listing, EngineError> {
|
|
||||||
let stdout = self.run(&["-J", "--flat-playlist", target]).await?;
|
|
||||||
Ok(serde_json::from_slice(&stdout)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata of one video (`-J --no-playlist`).
|
|
||||||
pub async fn video_entry(&self, video_url: &str) -> Result<Entry, EngineError> {
|
|
||||||
let stdout = self.run(&["-J", "--no-playlist", video_url]).await?;
|
|
||||||
Ok(serde_json::from_slice(&stdout)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream URL(s) of one video (`-f bestaudio/best -g --no-playlist`),
|
|
||||||
/// one per line.
|
|
||||||
pub async fn stream_urls(&self, video_url: &str) -> Result<Vec<String>, EngineError> {
|
|
||||||
let stdout = self
|
|
||||||
.run(&["-f", "bestaudio/best", "-g", "--no-playlist", video_url])
|
|
||||||
.await?;
|
|
||||||
Ok(String::from_utf8_lossy(&stdout)
|
|
||||||
.lines()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|line| !line.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the binary with `args` (plus `--no-warnings` and the cookie
|
|
||||||
/// flag), enforcing the timeout and stdout cap; returns raw stdout.
|
|
||||||
///
|
|
||||||
/// The child is `kill_on_drop`: hitting the timeout kills it instead
|
|
||||||
/// of leaking a hung extractor.
|
|
||||||
async fn run(&self, args: &[&str]) -> Result<Vec<u8>, EngineError> {
|
|
||||||
use std::process::Stdio;
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
|
|
||||||
let mut cmd = tokio::process::Command::new(&self.binary);
|
|
||||||
cmd.arg("--no-warnings");
|
|
||||||
if let Some(cookies) = &self.cookies {
|
|
||||||
cmd.arg("--cookies").arg(cookies);
|
|
||||||
}
|
|
||||||
cmd.args(args)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.kill_on_drop(true);
|
|
||||||
let mut child = cmd.spawn()?;
|
|
||||||
// Read stdout incrementally with a hard cap — `wait_with_output`
|
|
||||||
// alone would buffer an unbounded stream first.
|
|
||||||
let mut stdout_pipe = child.stdout.take().ok_or_else(|| {
|
|
||||||
std::io::Error::other("child stdout pipe missing despite Stdio::piped")
|
|
||||||
})?;
|
|
||||||
let bounded = async {
|
|
||||||
let mut stdout = Vec::new();
|
|
||||||
let mut limited = (&mut stdout_pipe).take(MAX_STDOUT_BYTES as u64 + 1);
|
|
||||||
limited.read_to_end(&mut stdout).await?;
|
|
||||||
let rest = child.wait_with_output().await?;
|
|
||||||
Ok::<_, std::io::Error>((stdout, rest))
|
|
||||||
};
|
|
||||||
let (stdout, rest) = tokio::time::timeout(self.timeout, bounded)
|
|
||||||
.await
|
|
||||||
.map_err(|_| EngineError::Timeout(self.timeout))??;
|
|
||||||
if stdout.len() > MAX_STDOUT_BYTES {
|
|
||||||
return Err(EngineError::Oversized);
|
|
||||||
}
|
|
||||||
if !rest.status.success() {
|
|
||||||
// A bounded summary only: full stderr can embed URLs.
|
|
||||||
let summary: String = String::from_utf8_lossy(&rest.stderr)
|
|
||||||
.chars()
|
|
||||||
.take(200)
|
|
||||||
.collect();
|
|
||||||
return Err(EngineError::Failed {
|
|
||||||
status: rest.status.to_string(),
|
|
||||||
summary: summary.trim().to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(stdout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
//! The extraction seam between the provider and YouTube
|
||||||
|
//! (see `architecture/youtube-rustypipe.md` D1).
|
||||||
|
//!
|
||||||
|
//! Everything the provider needs from YouTube goes through the
|
||||||
|
//! [`Extract`] trait: the real implementation wraps a pure-Rust
|
||||||
|
//! [`rustypipe`] Innertube client (no subprocess, no Python), tests use
|
||||||
|
//! a fake. All errors are typed; messages may name videos, playlists,
|
||||||
|
//! and public API endpoints — never cookie values or auth headers.
|
||||||
|
|
||||||
|
use std::fmt::Debug;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use rustypipe::client::RustyPipe;
|
||||||
|
use rustypipe::model::VideoItem;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
/// Errors from the extractor.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ExtractError {
|
||||||
|
/// The client could not be built (storage directory, TLS backend).
|
||||||
|
#[error("cannot build the youtube client: {0}")]
|
||||||
|
Client(String),
|
||||||
|
/// A YouTube request failed (network, extraction, API change).
|
||||||
|
#[error("youtube request failed: {0}")]
|
||||||
|
Fetch(String),
|
||||||
|
/// The video exists but offers no audio streams.
|
||||||
|
#[error("video has no audio streams")]
|
||||||
|
NoAudio,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One video as the provider sees it.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct VideoEntry {
|
||||||
|
/// YouTube video id (the track path's last segment).
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
/// Channel name; the wire track's artist.
|
||||||
|
pub artist: Option<String>,
|
||||||
|
/// Duration in seconds; `None` for livestreams.
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One playlist of the logged-in user.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PlaylistEntry {
|
||||||
|
/// YouTube playlist id (the node path's last segment).
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the provider needs from YouTube. Implementations must be
|
||||||
|
/// side-effect free towards the provider: every call is independent,
|
||||||
|
/// there is no session state beyond the login performed at init.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Extract: Send + Sync + Debug {
|
||||||
|
/// The top `limit` video results for a search query, result order.
|
||||||
|
async fn search_videos(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<VideoEntry>, ExtractError>;
|
||||||
|
/// Metadata of one video.
|
||||||
|
async fn video(&self, id: &str) -> Result<VideoEntry, ExtractError>;
|
||||||
|
/// The playable audio stream URL of one video — `audio/mp4` (AAC)
|
||||||
|
/// preferred, since the local player cannot decode Opus
|
||||||
|
/// (architecture/youtube-rustypipe.md D2).
|
||||||
|
async fn audio_stream_url(&self, id: &str) -> Result<String, ExtractError>;
|
||||||
|
/// The logged-in user's saved playlists (first page).
|
||||||
|
async fn saved_playlists(&self) -> Result<Vec<PlaylistEntry>, ExtractError>;
|
||||||
|
/// One playlist's name and up to `limit` videos (bounded
|
||||||
|
/// pagination).
|
||||||
|
async fn playlist_videos(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<(String, Vec<VideoEntry>), ExtractError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real extractor: one [`RustyPipe`] Innertube client.
|
||||||
|
pub struct RustyPipeExtractor {
|
||||||
|
rp: RustyPipe,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for RustyPipeExtractor {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("RustyPipeExtractor").finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a rustypipe failure to [`ExtractError::Fetch`]. rustypipe's
|
||||||
|
/// errors are typed (HTTP status, extraction, auth) and never carry
|
||||||
|
/// cookie values.
|
||||||
|
fn fetch_err(err: rustypipe::error::Error) -> ExtractError {
|
||||||
|
ExtractError::Fetch(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video_entry(item: VideoItem) -> VideoEntry {
|
||||||
|
VideoEntry {
|
||||||
|
id: item.id,
|
||||||
|
title: item.name,
|
||||||
|
artist: item.channel.map(|channel| channel.name),
|
||||||
|
duration: item.duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the audio stream to play from `(mime, average_bitrate)` pairs:
|
||||||
|
/// the highest-bitrate `audio/mp4` (AAC — the local rodio/symphonia
|
||||||
|
/// player decodes it), falling back to the highest-bitrate stream of
|
||||||
|
/// any type when no mp4 exists. Returns the index into the input.
|
||||||
|
fn pick_audio_stream(streams: &[(String, u32)]) -> Option<usize> {
|
||||||
|
let best = |mp4_only: bool| {
|
||||||
|
streams
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, (mime, _))| !mp4_only || mime.starts_with("audio/mp4"))
|
||||||
|
.max_by_key(|(_, (_, bitrate))| *bitrate)
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
};
|
||||||
|
best(true).or_else(|| best(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RustyPipeExtractor {
|
||||||
|
/// Builds the client. `storage_dir` holds rustypipe's cache file
|
||||||
|
/// (client state and the rotated auth cookie — as secret as the
|
||||||
|
/// cookies file itself); `timeout` bounds every YouTube request.
|
||||||
|
pub fn new(storage_dir: PathBuf, timeout: Duration) -> Result<Self, ExtractError> {
|
||||||
|
std::fs::create_dir_all(&storage_dir)
|
||||||
|
.map_err(|err| ExtractError::Client(format!("storage directory: {err}")))?;
|
||||||
|
let rp = RustyPipe::builder()
|
||||||
|
.storage_dir(storage_dir)
|
||||||
|
.timeout(timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| ExtractError::Client(err.to_string()))?;
|
||||||
|
Ok(Self { rp })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs in from a Netscape `cookies.txt` export, cache first
|
||||||
|
/// (architecture/youtube-rustypipe.md D3): a still-valid rotated
|
||||||
|
/// cookie in the rustypipe cache wins over re-reading the
|
||||||
|
/// stale-prone file. Returns whether the client is authenticated.
|
||||||
|
/// Never fails — anything short of a working login degrades to
|
||||||
|
/// logged-out with a warning naming only the file *path*.
|
||||||
|
pub async fn login(&self, cookies_file: Option<&Path>) -> bool {
|
||||||
|
let Some(path) = cookies_file else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// The cached cookie is refreshed by rustypipe on use and
|
||||||
|
// outlives the original export (YouTube rotates cookies).
|
||||||
|
if self.rp.user_auth_check_cookie().await.is_ok() {
|
||||||
|
debug!("youtube login restored from the rustypipe cache");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let contents = match std::fs::read_to_string(path) {
|
||||||
|
Ok(contents) => contents,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
cookies = %path.display(),
|
||||||
|
"cookies file not readable, running logged out: {err}"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match self.rp.user_auth_set_cookie_txt(&contents).await {
|
||||||
|
Ok(()) => {
|
||||||
|
debug!(cookies = %path.display(), "youtube login succeeded");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
cookies = %path.display(),
|
||||||
|
"youtube login failed, running logged out: {err}"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Extract for RustyPipeExtractor {
|
||||||
|
async fn search_videos(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<VideoEntry>, ExtractError> {
|
||||||
|
let result = self
|
||||||
|
.rp
|
||||||
|
.query()
|
||||||
|
.search::<VideoItem, _>(query)
|
||||||
|
.await
|
||||||
|
.map_err(fetch_err)?;
|
||||||
|
Ok(result
|
||||||
|
.items
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(video_entry)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn video(&self, id: &str) -> Result<VideoEntry, ExtractError> {
|
||||||
|
// `player` (not `video_details`) because it carries the
|
||||||
|
// duration — and it is the same call the stream fetch uses.
|
||||||
|
let player = self.rp.query().player(id).await.map_err(fetch_err)?;
|
||||||
|
let details = player.details;
|
||||||
|
Ok(VideoEntry {
|
||||||
|
id: details.id,
|
||||||
|
title: details.name.unwrap_or_default(),
|
||||||
|
artist: details.channel_name,
|
||||||
|
duration: (details.duration > 0).then_some(details.duration),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn audio_stream_url(&self, id: &str) -> Result<String, ExtractError> {
|
||||||
|
let player = self.rp.query().player(id).await.map_err(fetch_err)?;
|
||||||
|
let candidates: Vec<(String, u32)> = player
|
||||||
|
.audio_streams
|
||||||
|
.iter()
|
||||||
|
.map(|stream| (stream.mime.clone(), stream.average_bitrate))
|
||||||
|
.collect();
|
||||||
|
let Some(index) = pick_audio_stream(&candidates) else {
|
||||||
|
return Err(ExtractError::NoAudio);
|
||||||
|
};
|
||||||
|
let stream = &player.audio_streams[index];
|
||||||
|
if !stream.mime.starts_with("audio/mp4") {
|
||||||
|
// Playable URL, but the local player has no Opus decoder —
|
||||||
|
// hand it out anyway (a future player may cope) and say so.
|
||||||
|
warn!(video = id, mime = %stream.mime, "no mp4 audio stream; local decoding may fail");
|
||||||
|
}
|
||||||
|
Ok(stream.url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn saved_playlists(&self) -> Result<Vec<PlaylistEntry>, ExtractError> {
|
||||||
|
let playlists = self.rp.query().saved_playlists().await.map_err(fetch_err)?;
|
||||||
|
Ok(playlists
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| PlaylistEntry {
|
||||||
|
id: item.id,
|
||||||
|
title: item.name,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn playlist_videos(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<(String, Vec<VideoEntry>), ExtractError> {
|
||||||
|
let mut playlist = self.rp.query().playlist(id).await.map_err(fetch_err)?;
|
||||||
|
playlist
|
||||||
|
.videos
|
||||||
|
.extend_limit(self.rp.query(), limit)
|
||||||
|
.await
|
||||||
|
.map_err(fetch_err)?;
|
||||||
|
let videos = playlist
|
||||||
|
.videos
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(video_entry)
|
||||||
|
.collect();
|
||||||
|
Ok((playlist.name, videos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_pick_prefers_mp4_then_bitrate() {
|
||||||
|
let streams = |list: &[(&str, u32)]| -> Vec<(String, u32)> {
|
||||||
|
list.iter().map(|(m, b)| (m.to_string(), *b)).collect()
|
||||||
|
};
|
||||||
|
// The best mp4 wins even against a higher-bitrate opus.
|
||||||
|
let mixed = streams(&[
|
||||||
|
("audio/webm; codecs=\"opus\"", 128_000),
|
||||||
|
("audio/mp4; codecs=\"mp4a.40.2\"", 96_000),
|
||||||
|
("audio/mp4; codecs=\"mp4a.40.5\"", 48_000),
|
||||||
|
]);
|
||||||
|
assert_eq!(pick_audio_stream(&mixed), Some(1));
|
||||||
|
// Without mp4, the highest bitrate of anything is used.
|
||||||
|
let opus_only = streams(&[
|
||||||
|
("audio/webm; codecs=\"opus\"", 64_000),
|
||||||
|
("audio/webm; codecs=\"opus\"", 128_000),
|
||||||
|
]);
|
||||||
|
assert_eq!(pick_audio_stream(&opus_only), Some(1));
|
||||||
|
assert_eq!(pick_audio_stream(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_without_a_readable_file_degrades_quietly() {
|
||||||
|
// Network-free: no cached cookie (fresh storage dir) and a
|
||||||
|
// missing file short-circuit before any request.
|
||||||
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
let extractor =
|
||||||
|
RustyPipeExtractor::new(dir.path().join("rustypipe"), Duration::from_secs(5))
|
||||||
|
.expect("client builds");
|
||||||
|
assert!(!extractor.login(None).await);
|
||||||
|
assert!(!extractor.login(Some(&dir.path().join("gone.txt"))).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
498
ytdy/src/lib.rs
498
ytdy/src/lib.rs
|
|
@ -1,12 +1,13 @@
|
||||||
//! YouTube media provider, backed by a `yt-dlp` subprocess
|
//! YouTube media provider, backed by the pure-Rust `rustypipe`
|
||||||
//! (see `architecture/youtube-provider.md`).
|
//! Innertube client (see `architecture/youtube-provider.md` for the
|
||||||
|
//! tree shape and `architecture/youtube-rustypipe.md` for the engine).
|
||||||
//!
|
//!
|
||||||
//! Mounted at [`PROVIDER_ROOT`]. Search works without login: creatable
|
//! Mounted at [`PROVIDER_ROOT`]. Search works without login: creatable
|
||||||
//! search-term nodes exactly like `/tidal/search` (in-memory terms,
|
//! search-term nodes exactly like `/tidal/search` (in-memory terms,
|
||||||
//! renamable/deletable, results listed as tracks). With a cookies file
|
//! renamable/deletable, results listed as tracks). With a cookies file
|
||||||
//! configured ("logged in"), the user's playlists appear under
|
//! configured and accepted ("logged in"), the user's saved playlists
|
||||||
//! `/youtube/playlists`. Every node that serves tracks is downloadable —
|
//! appear under `/youtube/playlists`. Every node that serves tracks is
|
||||||
//! `W` captures work out of the box.
|
//! downloadable — `W` captures work out of the box.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -17,33 +18,36 @@ use crabidy_core::{ProviderClient, ProviderError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
pub mod engine;
|
pub mod extract;
|
||||||
use engine::Engine;
|
use extract::{Extract, ExtractError, RustyPipeExtractor, VideoEntry};
|
||||||
|
|
||||||
/// First path segment owned by this provider.
|
/// First path segment owned by this provider.
|
||||||
pub const PROVIDER_ROOT: &str = "/youtube";
|
pub const PROVIDER_ROOT: &str = "/youtube";
|
||||||
|
|
||||||
/// Default number of search results per term (`ytsearchN:`).
|
/// Default number of search results per term.
|
||||||
pub const DEFAULT_SEARCH_RESULTS: usize = 20;
|
pub const DEFAULT_SEARCH_RESULTS: usize = 20;
|
||||||
|
|
||||||
/// Default per-subprocess-call timeout in seconds.
|
/// Default per-request timeout in seconds.
|
||||||
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 60;
|
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Playlist nodes stop paging past this many tracks — a huge playlist
|
||||||
|
/// must not stall the library (or blow up queue resolution).
|
||||||
|
pub const MAX_PLAYLIST_TRACKS: usize = 1_000;
|
||||||
|
|
||||||
/// Provider settings, persisted as `ytdy.toml` next to the other crabidy
|
/// Provider settings, persisted as `ytdy.toml` next to the other crabidy
|
||||||
/// config files. The cookies file is user-provided and its **contents are
|
/// config files. The cookies file is user-provided and its **contents are
|
||||||
/// a secret**: only the path may ever be logged.
|
/// a secret**: only the path may ever be logged.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
/// The yt-dlp binary; a bare name resolves via PATH. Default
|
/// Netscape `cookies.txt` export for logged-in features (saved
|
||||||
/// `yt-dlp`.
|
/// playlists). Absent = logged out; search still works. The login
|
||||||
pub binary: Option<PathBuf>,
|
/// cookie is refreshed and cached by the client, so a once-accepted
|
||||||
/// Netscape cookies file for logged-in features (playlists,
|
/// file keeps working after YouTube rotates it.
|
||||||
/// age-gated streams). Absent = logged out; search still works.
|
|
||||||
pub cookies: Option<PathBuf>,
|
pub cookies: Option<PathBuf>,
|
||||||
/// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`].
|
/// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`].
|
||||||
pub search_results: Option<usize>,
|
pub search_results: Option<usize>,
|
||||||
/// Per-call timeout in seconds. Default
|
/// Per-request timeout in seconds. Default
|
||||||
/// [`DEFAULT_CALL_TIMEOUT_SECS`]; tests shrink it.
|
/// [`DEFAULT_CALL_TIMEOUT_SECS`].
|
||||||
pub call_timeout_secs: Option<u64>,
|
pub call_timeout_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,27 +94,21 @@ fn parse_path(path: &str) -> Result<YtPath<'_>, ProviderError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps an engine failure to the trait-level error, logging the typed
|
/// Maps an extractor failure to the trait-level error, logging the typed
|
||||||
/// cause (paths and statuses only — never URLs or cookie contents).
|
/// cause (ids and public endpoints only — never cookie contents).
|
||||||
fn engine_err(context: &str, err: engine::EngineError) -> ProviderError {
|
fn extract_err(context: &str, err: ExtractError) -> ProviderError {
|
||||||
warn!(context, "yt-dlp call failed: {err}");
|
warn!(context, "youtube extraction failed: {err}");
|
||||||
ProviderError::FetchError
|
ProviderError::FetchError
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The canonical watch URL for a video id.
|
/// Builds the wire track for one video entry under `node_path`.
|
||||||
fn video_url(video_id: &str) -> String {
|
/// Missing metadata degrades to empty fields, never an error.
|
||||||
format!("https://www.youtube.com/watch?v={video_id}")
|
fn entry_to_track(entry: &VideoEntry, node_path: &str) -> Track {
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the wire track for one listing entry under `node_path`.
|
|
||||||
/// Missing metadata degrades to empty fields, never an error; float
|
|
||||||
/// durations truncate to whole seconds.
|
|
||||||
fn entry_to_track(entry: &engine::Entry, node_path: &str) -> Track {
|
|
||||||
Track {
|
Track {
|
||||||
path: crabidy_core::join_path(node_path, &entry.id),
|
path: crabidy_core::join_path(node_path, &entry.id),
|
||||||
artist: entry.artist().unwrap_or_default().to_string(),
|
artist: entry.artist.clone().unwrap_or_default(),
|
||||||
title: entry.title.clone().unwrap_or_default(),
|
title: entry.title.clone(),
|
||||||
duration: entry.duration.map(|secs| secs.max(0.0) as u32),
|
duration: entry.duration,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
}
|
}
|
||||||
|
|
@ -119,10 +117,10 @@ fn entry_to_track(entry: &engine::Entry, node_path: &str) -> Track {
|
||||||
/// The YouTube provider client.
|
/// The YouTube provider client.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
engine: Engine,
|
extractor: Box<dyn Extract>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
/// Cookies were configured and readable at init — gates the
|
/// The login attempt at init succeeded — gates the playlists
|
||||||
/// playlists subtree.
|
/// subtree.
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
/// Search terms created under `/youtube/search`, in creation order,
|
/// Search terms created under `/youtube/search`, in creation order,
|
||||||
/// deduplicated. In-memory only, like tidal's. Never held across
|
/// deduplicated. In-memory only, like tidal's. Never held across
|
||||||
|
|
@ -131,9 +129,18 @@ pub struct Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
/// The search-term node: top `search_results` results as tracks
|
/// A client over any extractor — the seam the tests use.
|
||||||
/// (`ytsearchN:<term>` flat listing). Queueable and downloadable —
|
fn with_extractor(extractor: Box<dyn Extract>, settings: Settings, logged_in: bool) -> Self {
|
||||||
/// results are homogeneous tracks.
|
Self {
|
||||||
|
extractor,
|
||||||
|
settings,
|
||||||
|
logged_in,
|
||||||
|
search_terms: std::sync::RwLock::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The search-term node: top `search_results` results as tracks.
|
||||||
|
/// Queueable and downloadable — results are homogeneous tracks.
|
||||||
async fn search_term_node(
|
async fn search_term_node(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
|
@ -144,17 +151,16 @@ impl Client {
|
||||||
.settings
|
.settings
|
||||||
.search_results
|
.search_results
|
||||||
.unwrap_or(DEFAULT_SEARCH_RESULTS);
|
.unwrap_or(DEFAULT_SEARCH_RESULTS);
|
||||||
let listing = self
|
let entries = self
|
||||||
.engine
|
.extractor
|
||||||
.flat_listing(&format!("ytsearch{count}:{term}"))
|
.search_videos(term, count)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| engine_err("search", err))?;
|
.map_err(|err| extract_err("search", err))?;
|
||||||
Ok(LibraryNode {
|
Ok(LibraryNode {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
title: term.to_string(),
|
title: term.to_string(),
|
||||||
parent: Some(parent),
|
parent: Some(parent),
|
||||||
tracks: listing
|
tracks: entries
|
||||||
.entries
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| entry_to_track(entry, path))
|
.map(|entry| entry_to_track(entry, path))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|
@ -165,30 +171,29 @@ impl Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The playlists listing (`feed/playlists`, cookies required):
|
/// The saved-playlists listing (login required): one queueable,
|
||||||
/// one queueable, downloadable child per playlist.
|
/// downloadable child per playlist.
|
||||||
async fn playlists_node(
|
async fn playlists_node(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
parent: String,
|
parent: String,
|
||||||
) -> Result<LibraryNode, ProviderError> {
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
let listing = self
|
let playlists = self
|
||||||
.engine
|
.extractor
|
||||||
.flat_listing("https://www.youtube.com/feed/playlists")
|
.saved_playlists()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| engine_err("playlists feed", err))?;
|
.map_err(|err| extract_err("saved playlists", err))?;
|
||||||
Ok(LibraryNode {
|
Ok(LibraryNode {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
title: "playlists".to_string(),
|
title: "playlists".to_string(),
|
||||||
parent: Some(parent),
|
parent: Some(parent),
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
children: listing
|
children: playlists
|
||||||
.entries
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| {
|
.map(|playlist| {
|
||||||
LibraryNodeChild::new(
|
LibraryNodeChild::new(
|
||||||
crabidy_core::join_path(path, &entry.id),
|
crabidy_core::join_path(path, &playlist.id),
|
||||||
entry.title.clone().unwrap_or_else(|| entry.id.clone()),
|
playlist.title.clone(),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -199,29 +204,24 @@ impl Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One playlist's entries as tracks.
|
/// One playlist's entries as tracks (bounded by
|
||||||
|
/// [`MAX_PLAYLIST_TRACKS`]).
|
||||||
async fn playlist_node(
|
async fn playlist_node(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
playlist_id: &str,
|
playlist_id: &str,
|
||||||
parent: String,
|
parent: String,
|
||||||
) -> Result<LibraryNode, ProviderError> {
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
let listing = self
|
let (title, entries) = self
|
||||||
.engine
|
.extractor
|
||||||
.flat_listing(&format!(
|
.playlist_videos(playlist_id, MAX_PLAYLIST_TRACKS)
|
||||||
"https://www.youtube.com/playlist?list={playlist_id}"
|
|
||||||
))
|
|
||||||
.await
|
.await
|
||||||
.map_err(|err| engine_err("playlist", err))?;
|
.map_err(|err| extract_err("playlist", err))?;
|
||||||
Ok(LibraryNode {
|
Ok(LibraryNode {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
title: listing
|
title,
|
||||||
.title
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| playlist_id.to_string()),
|
|
||||||
parent: Some(parent),
|
parent: Some(parent),
|
||||||
tracks: listing
|
tracks: entries
|
||||||
.entries
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| entry_to_track(entry, path))
|
.map(|entry| entry_to_track(entry, path))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|
@ -262,53 +262,35 @@ impl Client {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ProviderClient for Client {
|
impl ProviderClient for Client {
|
||||||
/// Builds the engine from settings and probes `--version`; a missing
|
/// Builds the rustypipe client (state under
|
||||||
/// or broken binary fails init (the orchestrator disables the
|
/// `<config>/crabidy/rustypipe/`) and attempts the cookie login. A
|
||||||
/// provider non-fatally). A configured but unreadable cookies file
|
/// client that cannot be built fails init (the orchestrator disables
|
||||||
/// degrades to logged-out with a warning, never an error.
|
/// the provider non-fatally); a failed login degrades to logged-out
|
||||||
|
/// with a warning, never an error.
|
||||||
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
||||||
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
|
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
|
||||||
warn!("could not parse toml settings, using defaults");
|
warn!("could not parse toml settings, using defaults");
|
||||||
Settings::default()
|
Settings::default()
|
||||||
});
|
});
|
||||||
let binary = settings
|
|
||||||
.binary
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("yt-dlp"));
|
|
||||||
let timeout = Duration::from_secs(
|
let timeout = Duration::from_secs(
|
||||||
settings
|
settings
|
||||||
.call_timeout_secs
|
.call_timeout_secs
|
||||||
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
|
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
|
||||||
);
|
);
|
||||||
// "Logged in" is exactly "the configured cookies file is
|
let storage_dir = dirs::config_dir()
|
||||||
// readable"; anything less degrades to logged out, never to a
|
.map(|dir| dir.join("crabidy").join("rustypipe"))
|
||||||
// failed init. Only the *path* is ever logged.
|
.ok_or_else(|| ProviderError::Config("no config directory".to_string()))?;
|
||||||
let logged_in = match &settings.cookies {
|
let extractor = RustyPipeExtractor::new(storage_dir, timeout).map_err(|err| {
|
||||||
Some(path) => match std::fs::metadata(path) {
|
warn!("cannot build the youtube client: {err}");
|
||||||
Ok(meta) if meta.is_file() => true,
|
|
||||||
_ => {
|
|
||||||
warn!(
|
|
||||||
cookies = %path.display(),
|
|
||||||
"cookies file not readable; running logged out"
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
let cookies = logged_in.then(|| settings.cookies.clone()).flatten();
|
|
||||||
let engine = Engine::new(binary, cookies, timeout);
|
|
||||||
let version = engine.probe().await.map_err(|err| {
|
|
||||||
warn!("yt-dlp probe failed: {err}");
|
|
||||||
ProviderError::Config(err.to_string())
|
ProviderError::Config(err.to_string())
|
||||||
})?;
|
})?;
|
||||||
debug!(version, logged_in, "yt-dlp ready");
|
let logged_in = extractor.login(settings.cookies.as_deref()).await;
|
||||||
Ok(Self {
|
debug!(logged_in, "youtube extractor ready");
|
||||||
engine,
|
Ok(Self::with_extractor(
|
||||||
|
Box::new(extractor),
|
||||||
settings,
|
settings,
|
||||||
logged_in,
|
logged_in,
|
||||||
search_terms: std::sync::RwLock::new(Vec::new()),
|
))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settings(&self) -> String {
|
fn settings(&self) -> String {
|
||||||
|
|
@ -322,35 +304,33 @@ impl ProviderClient for Client {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `-f bestaudio/best -g` on the track's video id.
|
/// The video's audio stream URL — `audio/mp4` (AAC) preferred, the
|
||||||
|
/// format the local player decodes
|
||||||
|
/// (architecture/youtube-rustypipe.md D2).
|
||||||
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
let video = match parse_path(track_path)? {
|
let video = match parse_path(track_path)? {
|
||||||
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
||||||
_ => return Err(ProviderError::MalformedPath),
|
_ => return Err(ProviderError::MalformedPath),
|
||||||
};
|
};
|
||||||
let urls = self
|
let url = self
|
||||||
.engine
|
.extractor
|
||||||
.stream_urls(&video_url(video))
|
.audio_stream_url(video)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| engine_err("stream urls", err))?;
|
.map_err(|err| extract_err("stream url", err))?;
|
||||||
if urls.is_empty() {
|
Ok(vec![url])
|
||||||
warn!(path = track_path, "yt-dlp returned no stream url");
|
|
||||||
return Err(ProviderError::FetchError);
|
|
||||||
}
|
|
||||||
Ok(urls)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single-video `-J` metadata.
|
/// Single-video metadata.
|
||||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||||
let video = match parse_path(track_path)? {
|
let video = match parse_path(track_path)? {
|
||||||
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
||||||
_ => return Err(ProviderError::MalformedPath),
|
_ => return Err(ProviderError::MalformedPath),
|
||||||
};
|
};
|
||||||
let entry = self
|
let entry = self
|
||||||
.engine
|
.extractor
|
||||||
.video_entry(&video_url(video))
|
.video(video)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| engine_err("video metadata", err))?;
|
.map_err(|err| extract_err("video metadata", err))?;
|
||||||
let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT);
|
let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT);
|
||||||
let mut track = entry_to_track(&entry, parent);
|
let mut track = entry_to_track(&entry, parent);
|
||||||
// The entry's id names the video; the caller's path is canonical.
|
// The entry's id names the video; the caller's path is canonical.
|
||||||
|
|
@ -428,14 +408,14 @@ impl ProviderClient for Client {
|
||||||
}
|
}
|
||||||
YtPath::Playlists => {
|
YtPath::Playlists => {
|
||||||
if !self.logged_in {
|
if !self.logged_in {
|
||||||
warn!(path, "playlists need a configured cookies file");
|
warn!(path, "playlists need a working cookie login");
|
||||||
return Err(ProviderError::MalformedPath);
|
return Err(ProviderError::MalformedPath);
|
||||||
}
|
}
|
||||||
self.playlists_node(path, parent).await?
|
self.playlists_node(path, parent).await?
|
||||||
}
|
}
|
||||||
YtPath::Playlist(playlist_id) => {
|
YtPath::Playlist(playlist_id) => {
|
||||||
if !self.logged_in {
|
if !self.logged_in {
|
||||||
warn!(path, "playlists need a configured cookies file");
|
warn!(path, "playlists need a working cookie login");
|
||||||
return Err(ProviderError::MalformedPath);
|
return Err(ProviderError::MalformedPath);
|
||||||
}
|
}
|
||||||
self.playlist_node(path, playlist_id, parent).await?
|
self.playlist_node(path, playlist_id, parent).await?
|
||||||
|
|
@ -517,107 +497,137 @@ impl ProviderClient for Client {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
use extract::PlaylistEntry;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
/// A fake yt-dlp: a shell script dispatching on its argv. Every test
|
/// A programmable extractor: provider logic is tested without
|
||||||
/// engine call goes through it — no network, no real binary.
|
/// network (architecture/youtube-rustypipe.md D1).
|
||||||
fn fake_binary(dir: &Path, body: &str) -> PathBuf {
|
#[derive(Debug, Default)]
|
||||||
let path = dir.join("fake-yt-dlp");
|
struct FakeExtractor {
|
||||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake binary");
|
/// query → entries (the limit is applied like the real one).
|
||||||
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
searches: HashMap<String, Vec<VideoEntry>>,
|
||||||
perms.set_mode(0o755);
|
videos: HashMap<String, VideoEntry>,
|
||||||
fs::set_permissions(&path, perms).expect("chmod");
|
streams: HashMap<String, String>,
|
||||||
path
|
playlists: Vec<PlaylistEntry>,
|
||||||
|
playlist_videos: HashMap<String, (String, Vec<VideoEntry>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The standard fake: search terms `lofi` (2 results), one playlist
|
fn entry(id: &str, title: &str, artist: Option<&str>, duration: Option<u32>) -> VideoEntry {
|
||||||
/// feed, one playlist, and one resolvable video.
|
VideoEntry {
|
||||||
const FAKE: &str = r#"
|
id: id.to_string(),
|
||||||
case "$*" in
|
title: title.to_string(),
|
||||||
*--version*) echo "2026.06.09"; exit 0 ;;
|
artist: artist.map(str::to_string),
|
||||||
*ytsearch2:lofi\ beats*) printf '%s' '{"entries":[{"id":"vidA","title":"Beats","uploader":"Chan"}]}'; exit 0 ;;
|
duration,
|
||||||
*ytsearch2:lofi*) printf '%s' '{"entries":[{"id":"vid1","title":"Track One","uploader":"Chan","duration":63.4},{"id":"vid2","title":"Track Two","channel":"Chan Two"}]}'; exit 0 ;;
|
}
|
||||||
*-g*watch?v=vid1*|*watch?v=vid1*-g*) printf '%s\n%s\n' "https://example.test/a.webm" "https://example.test/b.webm"; exit 0 ;;
|
}
|
||||||
*watch?v=vid1*) printf '%s' '{"id":"vid1","title":"Track One","uploader":"Chan","duration":63.9}'; exit 0 ;;
|
|
||||||
*feed/playlists*) printf '%s' '{"entries":[{"id":"PL1","title":"Road Mix"}]}'; exit 0 ;;
|
|
||||||
*list=PL1*) printf '%s' '{"title":"Road Mix","entries":[{"id":"vid9","title":"Nine","uploader":"Chan","duration":10}]}'; exit 0 ;;
|
|
||||||
*) echo "unmatched: $*" >&2; exit 1 ;;
|
|
||||||
esac"#;
|
|
||||||
|
|
||||||
async fn client_with(dir: &Path, body: &str, cookies: Option<&Path>) -> Client {
|
#[async_trait]
|
||||||
let binary = fake_binary(dir, body);
|
impl Extract for FakeExtractor {
|
||||||
|
async fn search_videos(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<VideoEntry>, ExtractError> {
|
||||||
|
self.searches
|
||||||
|
.get(query)
|
||||||
|
.map(|entries| entries.iter().take(limit).cloned().collect())
|
||||||
|
.ok_or_else(|| ExtractError::Fetch(format!("no search fixture for {query:?}")))
|
||||||
|
}
|
||||||
|
async fn video(&self, id: &str) -> Result<VideoEntry, ExtractError> {
|
||||||
|
self.videos
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ExtractError::Fetch(format!("no video fixture for {id:?}")))
|
||||||
|
}
|
||||||
|
async fn audio_stream_url(&self, id: &str) -> Result<String, ExtractError> {
|
||||||
|
self.streams.get(id).cloned().ok_or(ExtractError::NoAudio)
|
||||||
|
}
|
||||||
|
async fn saved_playlists(&self) -> Result<Vec<PlaylistEntry>, ExtractError> {
|
||||||
|
Ok(self.playlists.clone())
|
||||||
|
}
|
||||||
|
async fn playlist_videos(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<(String, Vec<VideoEntry>), ExtractError> {
|
||||||
|
self.playlist_videos
|
||||||
|
.get(id)
|
||||||
|
.map(|(title, entries)| {
|
||||||
|
(title.clone(), entries.iter().take(limit).cloned().collect())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| ExtractError::Fetch(format!("no playlist fixture for {id:?}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standard fixture: two search terms, one playlist, one
|
||||||
|
/// resolvable video.
|
||||||
|
fn fake() -> FakeExtractor {
|
||||||
|
FakeExtractor {
|
||||||
|
searches: HashMap::from([
|
||||||
|
(
|
||||||
|
"lofi".to_string(),
|
||||||
|
vec![
|
||||||
|
entry("vid1", "Track One", Some("Chan"), Some(63)),
|
||||||
|
entry("vid2", "Track Two", Some("Chan Two"), None),
|
||||||
|
entry("vid3", "Track Three", None, Some(10)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lofi beats".to_string(),
|
||||||
|
vec![entry("vidA", "Beats", Some("Chan"), None)],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
videos: HashMap::from([(
|
||||||
|
"vid1".to_string(),
|
||||||
|
entry("vid1", "Track One", Some("Chan"), Some(63)),
|
||||||
|
)]),
|
||||||
|
streams: HashMap::from([(
|
||||||
|
"vid1".to_string(),
|
||||||
|
"https://example.test/a.m4a".to_string(),
|
||||||
|
)]),
|
||||||
|
playlists: vec![PlaylistEntry {
|
||||||
|
id: "PL1".to_string(),
|
||||||
|
title: "Road Mix".to_string(),
|
||||||
|
}],
|
||||||
|
playlist_videos: HashMap::from([(
|
||||||
|
"PL1".to_string(),
|
||||||
|
(
|
||||||
|
"Road Mix".to_string(),
|
||||||
|
vec![entry("vid9", "Nine", Some("Chan"), Some(10))],
|
||||||
|
),
|
||||||
|
)]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_with(extractor: FakeExtractor, logged_in: bool) -> Client {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
binary: Some(binary),
|
|
||||||
cookies: cookies.map(Path::to_path_buf),
|
|
||||||
search_results: Some(2),
|
search_results: Some(2),
|
||||||
// Generous: under full-workspace parallel test load, process
|
|
||||||
// spawn latency has flaked a 5 s budget.
|
|
||||||
call_timeout_secs: Some(30),
|
|
||||||
};
|
|
||||||
let toml = toml::to_string(&settings).expect("settings toml");
|
|
||||||
Client::init(&toml).await.expect("init")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn client(dir: &Path) -> Client {
|
|
||||||
client_with(dir, FAKE, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn init_probes_the_binary() {
|
|
||||||
let dir = TempDir::new().expect("tempdir");
|
|
||||||
// A working probe succeeds…
|
|
||||||
let _ = client(dir.path()).await;
|
|
||||||
// …a missing binary fails init (the orchestrator treats that as
|
|
||||||
// "provider disabled", never as a server error).
|
|
||||||
let settings = Settings {
|
|
||||||
binary: Some(dir.path().join("no-such-binary")),
|
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
};
|
};
|
||||||
let toml = toml::to_string(&settings).expect("settings toml");
|
Client::with_extractor(Box::new(extractor), settings, logged_in)
|
||||||
assert!(Client::init(&toml).await.is_err());
|
|
||||||
// …and so does one that exits non-zero on --version.
|
|
||||||
let broken = TempDir::new().expect("tempdir");
|
|
||||||
fake_binary(broken.path(), "exit 3");
|
|
||||||
let settings = Settings {
|
|
||||||
binary: Some(broken.path().join("fake-yt-dlp")),
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
let toml = toml::to_string(&settings).expect("settings toml");
|
|
||||||
assert!(Client::init(&toml).await.is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn client() -> Client {
|
||||||
async fn root_lists_playlists_only_when_logged_in() {
|
client_with(fake(), false)
|
||||||
let dir = TempDir::new().expect("tempdir");
|
}
|
||||||
let anon = client(dir.path()).await;
|
|
||||||
|
#[test]
|
||||||
|
fn root_lists_playlists_only_when_logged_in() {
|
||||||
|
let anon = client();
|
||||||
let root = anon.get_lib_root();
|
let root = anon.get_lib_root();
|
||||||
let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
|
let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
|
||||||
assert_eq!(titles, vec!["search"]);
|
assert_eq!(titles, vec!["search"]);
|
||||||
assert!(root.children[0].is_creatable, "search is creatable");
|
assert!(root.children[0].is_creatable, "search is creatable");
|
||||||
|
|
||||||
let cookies = dir.path().join("cookies.txt");
|
let logged_in = client_with(fake(), true);
|
||||||
fs::write(&cookies, "# Netscape HTTP Cookie File\n").expect("cookies");
|
|
||||||
let dir2 = TempDir::new().expect("tempdir");
|
|
||||||
let logged_in = client_with(dir2.path(), FAKE, Some(&cookies)).await;
|
|
||||||
let root = logged_in.get_lib_root();
|
let root = logged_in.get_lib_root();
|
||||||
let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
|
let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
|
||||||
assert_eq!(titles, vec!["search", "playlists"]);
|
assert_eq!(titles, vec!["search", "playlists"]);
|
||||||
|
|
||||||
// A configured but unreadable cookies file degrades to logged
|
|
||||||
// out instead of failing init.
|
|
||||||
let dir3 = TempDir::new().expect("tempdir");
|
|
||||||
let missing = dir3.path().join("gone.txt");
|
|
||||||
let degraded = client_with(dir3.path(), FAKE, Some(&missing)).await;
|
|
||||||
assert_eq!(degraded.get_lib_root().children.len(), 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_terms_are_created_listed_and_searched() {
|
async fn search_terms_are_created_listed_and_searched() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let client = client();
|
||||||
let client = client(dir.path()).await;
|
|
||||||
let node = client
|
let node = client
|
||||||
.create_lib_node("/youtube/search", "lofi")
|
.create_lib_node("/youtube/search", "lofi")
|
||||||
.await
|
.await
|
||||||
|
|
@ -625,13 +635,13 @@ esac"#;
|
||||||
assert_eq!(node.path, "/youtube/search/lofi");
|
assert_eq!(node.path, "/youtube/search/lofi");
|
||||||
assert!(node.is_queable, "pure track results are queueable");
|
assert!(node.is_queable, "pure track results are queueable");
|
||||||
assert!(node.is_downloadable, "search results are downloadable");
|
assert!(node.is_downloadable, "search results are downloadable");
|
||||||
|
// The configured limit (2) caps the fixture's three results.
|
||||||
assert_eq!(node.tracks.len(), 2);
|
assert_eq!(node.tracks.len(), 2);
|
||||||
let one = &node.tracks[0];
|
let one = &node.tracks[0];
|
||||||
assert_eq!(one.path, "/youtube/search/lofi/vid1");
|
assert_eq!(one.path, "/youtube/search/lofi/vid1");
|
||||||
assert_eq!(one.title, "Track One");
|
assert_eq!(one.title, "Track One");
|
||||||
assert_eq!(one.artist, "Chan");
|
assert_eq!(one.artist, "Chan");
|
||||||
assert_eq!(one.duration, Some(63));
|
assert_eq!(one.duration, Some(63));
|
||||||
// `channel` is an accepted alias for the artist.
|
|
||||||
assert_eq!(node.tracks[1].artist, "Chan Two");
|
assert_eq!(node.tracks[1].artist, "Chan Two");
|
||||||
|
|
||||||
// The search node lists the term as an editable/deletable child.
|
// The search node lists the term as an editable/deletable child.
|
||||||
|
|
@ -657,8 +667,7 @@ esac"#;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_terms_rename_and_delete() {
|
async fn search_terms_rename_and_delete() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let client = client();
|
||||||
let client = client(dir.path()).await;
|
|
||||||
let _ = client
|
let _ = client
|
||||||
.create_lib_node("/youtube/search", "lofi beats")
|
.create_lib_node("/youtube/search", "lofi beats")
|
||||||
.await
|
.await
|
||||||
|
|
@ -689,37 +698,32 @@ esac"#;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn playlists_list_and_resolve_when_logged_in() {
|
async fn playlists_list_and_resolve_when_logged_in() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let logged_in = client_with(fake(), true);
|
||||||
let cookies = dir.path().join("cookies.txt");
|
|
||||||
fs::write(&cookies, "# cookies\n").expect("cookies");
|
|
||||||
let client = client_with(dir.path(), FAKE, Some(&cookies)).await;
|
|
||||||
|
|
||||||
let playlists = client
|
let playlists = logged_in
|
||||||
.get_lib_node("/youtube/playlists")
|
.get_lib_node("/youtube/playlists")
|
||||||
.await
|
.await
|
||||||
.expect("playlists feed");
|
.expect("saved playlists");
|
||||||
assert_eq!(playlists.children.len(), 1);
|
assert_eq!(playlists.children.len(), 1);
|
||||||
let pl = &playlists.children[0];
|
let pl = &playlists.children[0];
|
||||||
assert_eq!(pl.path, "/youtube/playlists/PL1");
|
assert_eq!(pl.path, "/youtube/playlists/PL1");
|
||||||
assert_eq!(pl.title, "Road Mix");
|
assert_eq!(pl.title, "Road Mix");
|
||||||
assert!(pl.is_queable && pl.is_downloadable);
|
assert!(pl.is_queable && pl.is_downloadable);
|
||||||
|
|
||||||
let node = client.get_lib_node(&pl.path).await.expect("playlist");
|
let node = logged_in.get_lib_node(&pl.path).await.expect("playlist");
|
||||||
assert_eq!(node.title, "Road Mix");
|
assert_eq!(node.title, "Road Mix");
|
||||||
assert!(node.is_queable && node.is_downloadable);
|
assert!(node.is_queable && node.is_downloadable);
|
||||||
assert_eq!(node.tracks.len(), 1);
|
assert_eq!(node.tracks.len(), 1);
|
||||||
assert_eq!(node.tracks[0].path, "/youtube/playlists/PL1/vid9");
|
assert_eq!(node.tracks[0].path, "/youtube/playlists/PL1/vid9");
|
||||||
|
|
||||||
// Logged out, the playlists subtree is a malformed path.
|
// Logged out, the playlists subtree is a malformed path.
|
||||||
let dir2 = TempDir::new().expect("tempdir");
|
let anon = client();
|
||||||
let anon = client_with(dir2.path(), FAKE, None).await;
|
|
||||||
assert!(anon.get_lib_node("/youtube/playlists").await.is_err());
|
assert!(anon.get_lib_node("/youtube/playlists").await.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tracks_resolve_streams_and_metadata() {
|
async fn tracks_resolve_streams_and_metadata() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let client = client();
|
||||||
let client = client(dir.path()).await;
|
|
||||||
assert!(client.is_track_path("/youtube/search/lofi/vid1"));
|
assert!(client.is_track_path("/youtube/search/lofi/vid1"));
|
||||||
assert!(client.is_track_path("/youtube/playlists/PL1/vid9"));
|
assert!(client.is_track_path("/youtube/playlists/PL1/vid9"));
|
||||||
assert!(!client.is_track_path("/youtube/search/lofi"));
|
assert!(!client.is_track_path("/youtube/search/lofi"));
|
||||||
|
|
@ -727,14 +731,8 @@ esac"#;
|
||||||
let urls = client
|
let urls = client
|
||||||
.get_urls_for_track("/youtube/search/lofi/vid1")
|
.get_urls_for_track("/youtube/search/lofi/vid1")
|
||||||
.await
|
.await
|
||||||
.expect("stream urls");
|
.expect("stream url");
|
||||||
assert_eq!(
|
assert_eq!(urls, vec!["https://example.test/a.m4a".to_string()]);
|
||||||
urls,
|
|
||||||
vec![
|
|
||||||
"https://example.test/a.webm".to_string(),
|
|
||||||
"https://example.test/b.webm".into()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
let track = client
|
let track = client
|
||||||
.get_metadata_for_track("/youtube/playlists/PL1/vid1")
|
.get_metadata_for_track("/youtube/playlists/PL1/vid1")
|
||||||
.await
|
.await
|
||||||
|
|
@ -746,62 +744,36 @@ esac"#;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn engine_failures_are_typed_never_panics() {
|
async fn extractor_failures_are_typed_never_panics() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
// An empty fake answers nothing: every lookup is a typed error.
|
||||||
// Non-zero exit on everything but the probe.
|
let client = client_with(FakeExtractor::default(), true);
|
||||||
let body = r#"
|
|
||||||
case "$*" in
|
|
||||||
*--version*) echo 1.0; exit 0 ;;
|
|
||||||
*) echo boom >&2; exit 1 ;;
|
|
||||||
esac"#;
|
|
||||||
let client = client_with(dir.path(), body, None).await;
|
|
||||||
assert!(client.get_lib_node("/youtube/search/lofi").await.is_err());
|
assert!(client.get_lib_node("/youtube/search/lofi").await.is_err());
|
||||||
assert!(client
|
assert!(client
|
||||||
.get_urls_for_track("/youtube/search/lofi/vid1")
|
.get_urls_for_track("/youtube/search/lofi/vid1")
|
||||||
.await
|
.await
|
||||||
.is_err());
|
.is_err());
|
||||||
|
assert!(client
|
||||||
// Malformed JSON is a typed error, not a panic.
|
.get_metadata_for_track("/youtube/search/lofi/vid1")
|
||||||
let dir2 = TempDir::new().expect("tempdir");
|
.await
|
||||||
let body = r#"
|
.is_err());
|
||||||
case "$*" in
|
assert!(client.get_lib_node("/youtube/playlists/PL1").await.is_err());
|
||||||
*--version*) echo 1.0; exit 0 ;;
|
|
||||||
*) printf 'not json'; exit 0 ;;
|
|
||||||
esac"#;
|
|
||||||
let client = client_with(dir2.path(), body, None).await;
|
|
||||||
assert!(client.get_lib_node("/youtube/search/lofi").await.is_err());
|
|
||||||
|
|
||||||
// A hung binary hits the per-call timeout.
|
|
||||||
let dir3 = TempDir::new().expect("tempdir");
|
|
||||||
let body = r#"
|
|
||||||
case "$*" in
|
|
||||||
*--version*) echo 1.0; exit 0 ;;
|
|
||||||
*) sleep 30 ;;
|
|
||||||
esac"#;
|
|
||||||
let binary = fake_binary(dir3.path(), body);
|
|
||||||
let settings = Settings {
|
|
||||||
binary: Some(binary),
|
|
||||||
call_timeout_secs: Some(1),
|
|
||||||
search_results: Some(2),
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
let toml = toml::to_string(&settings).expect("settings toml");
|
|
||||||
let client = Client::init(&toml).await.expect("init");
|
|
||||||
let started = std::time::Instant::now();
|
|
||||||
assert!(client.get_lib_node("/youtube/search/lofi").await.is_err());
|
|
||||||
assert!(
|
|
||||||
started.elapsed() < Duration::from_secs(10),
|
|
||||||
"timed out late"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn foreign_and_malformed_paths_are_rejected() {
|
async fn foreign_and_malformed_paths_are_rejected() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let client = client();
|
||||||
let client = client(dir.path()).await;
|
|
||||||
for path in ["/tidal/artists", "/youtube/nope", "/youtube/search/a/b/c"] {
|
for path in ["/tidal/artists", "/youtube/nope", "/youtube/search/a/b/c"] {
|
||||||
assert!(client.get_lib_node(path).await.is_err(), "{path}");
|
assert!(client.get_lib_node(path).await.is_err(), "{path}");
|
||||||
}
|
}
|
||||||
assert!(client.create_lib_node("/youtube", "term").await.is_err());
|
assert!(client.create_lib_node("/youtube", "term").await.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_tolerate_the_retired_binary_key() {
|
||||||
|
// Old ytdy.toml files carry `binary = "yt-dlp"`; parsing must
|
||||||
|
// ignore it instead of resetting the whole config.
|
||||||
|
let settings: Settings =
|
||||||
|
toml::from_str("binary = \"yt-dlp\"\nsearch_results = 7\n").expect("parses");
|
||||||
|
assert_eq!(settings.search_results, Some(7));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue