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.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
|
|
@ -11,6 +17,21 @@ dependencies = [
|
|||
"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]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
|
|
@ -143,6 +164,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
|
|
@ -363,7 +396,16 @@ version = "0.5.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||
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]]
|
||||
|
|
@ -372,6 +414,12 @@ version = "0.6.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
|
@ -415,6 +463,36 @@ dependencies = [
|
|||
"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]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
|
|
@ -530,6 +608,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
|||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
|
|
@ -634,6 +713,24 @@ dependencies = [
|
|||
"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]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -762,7 +859,7 @@ dependencies = [
|
|||
"fsdy",
|
||||
"futures",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
|
|
@ -777,6 +874,15 @@ dependencies = [
|
|||
"ytdy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
|
|
@ -911,6 +1017,9 @@ name = "deranged"
|
|||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
|
|
@ -1145,10 +1254,21 @@ version = "0.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-set 0.5.3",
|
||||
"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]]
|
||||
name = "fast-srgb8"
|
||||
version = "1.0.0"
|
||||
|
|
@ -1199,6 +1319,16 @@ version = "0.5.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
|
|
@ -1605,6 +1735,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1984,6 +2115,16 @@ version = "1.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
|
|
@ -2101,6 +2242,16 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
|
|
@ -3090,6 +3241,44 @@ version = "0.8.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "reqwest"
|
||||
version = "0.13.1"
|
||||
|
|
@ -3141,6 +3330,16 @@ version = "0.7.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
|
|
@ -3168,6 +3367,33 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
|
|
@ -3204,6 +3430,7 @@ checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
|||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
|
|
@ -3277,6 +3504,35 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
|
|
@ -3379,6 +3635,15 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_plain"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
|
|
@ -3411,6 +3676,45 @@ dependencies = [
|
|||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -3468,6 +3772,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
|
|
@ -3529,7 +3839,7 @@ dependencies = [
|
|||
"mediatype",
|
||||
"parking_lot",
|
||||
"rangemap",
|
||||
"reqwest",
|
||||
"reqwest 0.13.1",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
|
|
@ -3897,7 +4207,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"base64",
|
||||
"bitflags 2.13.1",
|
||||
"fancy-regex",
|
||||
"fancy-regex 0.11.0",
|
||||
"filedescriptor",
|
||||
"finl_unicode",
|
||||
"fixedbitset 0.4.2",
|
||||
|
|
@ -3988,7 +4298,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"crabidy-core",
|
||||
"flume",
|
||||
"reqwest",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
|
|
@ -4262,12 +4572,17 @@ version = "0.6.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -4292,6 +4607,7 @@ version = "0.1.44"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
|
@ -4424,6 +4740,12 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
@ -4442,6 +4764,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
@ -4737,6 +5065,16 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
|
|
@ -4780,6 +5118,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
|
|
@ -4893,6 +5241,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
|
@ -5154,8 +5511,9 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"crabidy-core",
|
||||
"dirs",
|
||||
"rustypipe",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ rodio = { version = "0.22", default-features = false, features = [
|
|||
"playback",
|
||||
"symphonia-all",
|
||||
] }
|
||||
rustypipe = { version = "0.11", default-features = false, features = [
|
||||
"rustls-tls-webpki-roots",
|
||||
"userdata",
|
||||
] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# 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
|
||||
|
||||
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
|
||||
pkg-config
|
||||
protobuf
|
||||
# Extraction engine for the ytdy provider (architecture/youtube-provider.md D1).
|
||||
yt-dlp
|
||||
];
|
||||
in
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,43 @@
|
|||
# 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)
|
||||
|
||||
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]
|
||||
async-trait.workspace = true
|
||||
crabidy-core.workspace = true
|
||||
dirs.workspace = true
|
||||
rustypipe.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = [
|
||||
"time",
|
||||
"sync",
|
||||
"macros",
|
||||
"process",
|
||||
"io-util",
|
||||
] }
|
||||
toml.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
|
||||
//! (see `architecture/youtube-provider.md`).
|
||||
//! YouTube media provider, backed by the pure-Rust `rustypipe`
|
||||
//! 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
|
||||
//! search-term nodes exactly like `/tidal/search` (in-memory terms,
|
||||
//! renamable/deletable, results listed as tracks). With a cookies file
|
||||
//! configured ("logged in"), the user's playlists appear under
|
||||
//! `/youtube/playlists`. Every node that serves tracks is downloadable —
|
||||
//! `W` captures work out of the box.
|
||||
//! configured and accepted ("logged in"), the user's saved playlists
|
||||
//! appear under `/youtube/playlists`. Every node that serves tracks is
|
||||
//! downloadable — `W` captures work out of the box.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
|
@ -17,33 +18,36 @@ use crabidy_core::{ProviderClient, ProviderError};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
pub mod engine;
|
||||
use engine::Engine;
|
||||
pub mod extract;
|
||||
use extract::{Extract, ExtractError, RustyPipeExtractor, VideoEntry};
|
||||
|
||||
/// First path segment owned by this provider.
|
||||
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;
|
||||
|
||||
/// Default per-subprocess-call timeout in seconds.
|
||||
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 60;
|
||||
/// Default per-request timeout in seconds.
|
||||
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
|
||||
/// config files. The cookies file is user-provided and its **contents are
|
||||
/// a secret**: only the path may ever be logged.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Settings {
|
||||
/// The yt-dlp binary; a bare name resolves via PATH. Default
|
||||
/// `yt-dlp`.
|
||||
pub binary: Option<PathBuf>,
|
||||
/// Netscape cookies file for logged-in features (playlists,
|
||||
/// age-gated streams). Absent = logged out; search still works.
|
||||
/// Netscape `cookies.txt` export for logged-in features (saved
|
||||
/// playlists). Absent = logged out; search still works. The login
|
||||
/// cookie is refreshed and cached by the client, so a once-accepted
|
||||
/// file keeps working after YouTube rotates it.
|
||||
pub cookies: Option<PathBuf>,
|
||||
/// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`].
|
||||
pub search_results: Option<usize>,
|
||||
/// Per-call timeout in seconds. Default
|
||||
/// [`DEFAULT_CALL_TIMEOUT_SECS`]; tests shrink it.
|
||||
/// Per-request timeout in seconds. Default
|
||||
/// [`DEFAULT_CALL_TIMEOUT_SECS`].
|
||||
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
|
||||
/// cause (paths and statuses only — never URLs or cookie contents).
|
||||
fn engine_err(context: &str, err: engine::EngineError) -> ProviderError {
|
||||
warn!(context, "yt-dlp call failed: {err}");
|
||||
/// Maps an extractor failure to the trait-level error, logging the typed
|
||||
/// cause (ids and public endpoints only — never cookie contents).
|
||||
fn extract_err(context: &str, err: ExtractError) -> ProviderError {
|
||||
warn!(context, "youtube extraction failed: {err}");
|
||||
ProviderError::FetchError
|
||||
}
|
||||
|
||||
/// The canonical watch URL for a video id.
|
||||
fn video_url(video_id: &str) -> String {
|
||||
format!("https://www.youtube.com/watch?v={video_id}")
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Builds the wire track for one video entry under `node_path`.
|
||||
/// Missing metadata degrades to empty fields, never an error.
|
||||
fn entry_to_track(entry: &VideoEntry, node_path: &str) -> Track {
|
||||
Track {
|
||||
path: crabidy_core::join_path(node_path, &entry.id),
|
||||
artist: entry.artist().unwrap_or_default().to_string(),
|
||||
title: entry.title.clone().unwrap_or_default(),
|
||||
duration: entry.duration.map(|secs| secs.max(0.0) as u32),
|
||||
artist: entry.artist.clone().unwrap_or_default(),
|
||||
title: entry.title.clone(),
|
||||
duration: entry.duration,
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
}
|
||||
|
|
@ -119,10 +117,10 @@ fn entry_to_track(entry: &engine::Entry, node_path: &str) -> Track {
|
|||
/// The YouTube provider client.
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
engine: Engine,
|
||||
extractor: Box<dyn Extract>,
|
||||
settings: Settings,
|
||||
/// Cookies were configured and readable at init — gates the
|
||||
/// playlists subtree.
|
||||
/// The login attempt at init succeeded — gates the playlists
|
||||
/// subtree.
|
||||
logged_in: bool,
|
||||
/// Search terms created under `/youtube/search`, in creation order,
|
||||
/// deduplicated. In-memory only, like tidal's. Never held across
|
||||
|
|
@ -131,9 +129,18 @@ pub struct Client {
|
|||
}
|
||||
|
||||
impl Client {
|
||||
/// The search-term node: top `search_results` results as tracks
|
||||
/// (`ytsearchN:<term>` flat listing). Queueable and downloadable —
|
||||
/// results are homogeneous tracks.
|
||||
/// A client over any extractor — the seam the tests use.
|
||||
fn with_extractor(extractor: Box<dyn Extract>, settings: Settings, logged_in: bool) -> Self {
|
||||
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(
|
||||
&self,
|
||||
path: &str,
|
||||
|
|
@ -144,17 +151,16 @@ impl Client {
|
|||
.settings
|
||||
.search_results
|
||||
.unwrap_or(DEFAULT_SEARCH_RESULTS);
|
||||
let listing = self
|
||||
.engine
|
||||
.flat_listing(&format!("ytsearch{count}:{term}"))
|
||||
let entries = self
|
||||
.extractor
|
||||
.search_videos(term, count)
|
||||
.await
|
||||
.map_err(|err| engine_err("search", err))?;
|
||||
.map_err(|err| extract_err("search", err))?;
|
||||
Ok(LibraryNode {
|
||||
path: path.to_string(),
|
||||
title: term.to_string(),
|
||||
parent: Some(parent),
|
||||
tracks: listing
|
||||
.entries
|
||||
tracks: entries
|
||||
.iter()
|
||||
.map(|entry| entry_to_track(entry, path))
|
||||
.collect(),
|
||||
|
|
@ -165,30 +171,29 @@ impl Client {
|
|||
})
|
||||
}
|
||||
|
||||
/// The playlists listing (`feed/playlists`, cookies required):
|
||||
/// one queueable, downloadable child per playlist.
|
||||
/// The saved-playlists listing (login required): one queueable,
|
||||
/// downloadable child per playlist.
|
||||
async fn playlists_node(
|
||||
&self,
|
||||
path: &str,
|
||||
parent: String,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
let listing = self
|
||||
.engine
|
||||
.flat_listing("https://www.youtube.com/feed/playlists")
|
||||
let playlists = self
|
||||
.extractor
|
||||
.saved_playlists()
|
||||
.await
|
||||
.map_err(|err| engine_err("playlists feed", err))?;
|
||||
.map_err(|err| extract_err("saved playlists", err))?;
|
||||
Ok(LibraryNode {
|
||||
path: path.to_string(),
|
||||
title: "playlists".to_string(),
|
||||
parent: Some(parent),
|
||||
tracks: Vec::new(),
|
||||
children: listing
|
||||
.entries
|
||||
children: playlists
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
.map(|playlist| {
|
||||
LibraryNodeChild::new(
|
||||
crabidy_core::join_path(path, &entry.id),
|
||||
entry.title.clone().unwrap_or_else(|| entry.id.clone()),
|
||||
crabidy_core::join_path(path, &playlist.id),
|
||||
playlist.title.clone(),
|
||||
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(
|
||||
&self,
|
||||
path: &str,
|
||||
playlist_id: &str,
|
||||
parent: String,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
let listing = self
|
||||
.engine
|
||||
.flat_listing(&format!(
|
||||
"https://www.youtube.com/playlist?list={playlist_id}"
|
||||
))
|
||||
let (title, entries) = self
|
||||
.extractor
|
||||
.playlist_videos(playlist_id, MAX_PLAYLIST_TRACKS)
|
||||
.await
|
||||
.map_err(|err| engine_err("playlist", err))?;
|
||||
.map_err(|err| extract_err("playlist", err))?;
|
||||
Ok(LibraryNode {
|
||||
path: path.to_string(),
|
||||
title: listing
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| playlist_id.to_string()),
|
||||
title,
|
||||
parent: Some(parent),
|
||||
tracks: listing
|
||||
.entries
|
||||
tracks: entries
|
||||
.iter()
|
||||
.map(|entry| entry_to_track(entry, path))
|
||||
.collect(),
|
||||
|
|
@ -262,53 +262,35 @@ impl Client {
|
|||
|
||||
#[async_trait]
|
||||
impl ProviderClient for Client {
|
||||
/// Builds the engine from settings and probes `--version`; a missing
|
||||
/// or broken binary fails init (the orchestrator disables the
|
||||
/// provider non-fatally). A configured but unreadable cookies file
|
||||
/// degrades to logged-out with a warning, never an error.
|
||||
/// Builds the rustypipe client (state under
|
||||
/// `<config>/crabidy/rustypipe/`) and attempts the cookie login. A
|
||||
/// client that cannot be built fails init (the orchestrator disables
|
||||
/// 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> {
|
||||
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
|
||||
warn!("could not parse toml settings, using defaults");
|
||||
Settings::default()
|
||||
});
|
||||
let binary = settings
|
||||
.binary
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from("yt-dlp"));
|
||||
let timeout = Duration::from_secs(
|
||||
settings
|
||||
.call_timeout_secs
|
||||
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
|
||||
);
|
||||
// "Logged in" is exactly "the configured cookies file is
|
||||
// readable"; anything less degrades to logged out, never to a
|
||||
// failed init. Only the *path* is ever logged.
|
||||
let logged_in = match &settings.cookies {
|
||||
Some(path) => match std::fs::metadata(path) {
|
||||
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}");
|
||||
let storage_dir = dirs::config_dir()
|
||||
.map(|dir| dir.join("crabidy").join("rustypipe"))
|
||||
.ok_or_else(|| ProviderError::Config("no config directory".to_string()))?;
|
||||
let extractor = RustyPipeExtractor::new(storage_dir, timeout).map_err(|err| {
|
||||
warn!("cannot build the youtube client: {err}");
|
||||
ProviderError::Config(err.to_string())
|
||||
})?;
|
||||
debug!(version, logged_in, "yt-dlp ready");
|
||||
Ok(Self {
|
||||
engine,
|
||||
let logged_in = extractor.login(settings.cookies.as_deref()).await;
|
||||
debug!(logged_in, "youtube extractor ready");
|
||||
Ok(Self::with_extractor(
|
||||
Box::new(extractor),
|
||||
settings,
|
||||
logged_in,
|
||||
search_terms: std::sync::RwLock::new(Vec::new()),
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
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> {
|
||||
let video = match parse_path(track_path)? {
|
||||
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
||||
_ => return Err(ProviderError::MalformedPath),
|
||||
};
|
||||
let urls = self
|
||||
.engine
|
||||
.stream_urls(&video_url(video))
|
||||
let url = self
|
||||
.extractor
|
||||
.audio_stream_url(video)
|
||||
.await
|
||||
.map_err(|err| engine_err("stream urls", err))?;
|
||||
if urls.is_empty() {
|
||||
warn!(path = track_path, "yt-dlp returned no stream url");
|
||||
return Err(ProviderError::FetchError);
|
||||
}
|
||||
Ok(urls)
|
||||
.map_err(|err| extract_err("stream url", err))?;
|
||||
Ok(vec![url])
|
||||
}
|
||||
|
||||
/// Single-video `-J` metadata.
|
||||
/// Single-video metadata.
|
||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||
let video = match parse_path(track_path)? {
|
||||
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
||||
_ => return Err(ProviderError::MalformedPath),
|
||||
};
|
||||
let entry = self
|
||||
.engine
|
||||
.video_entry(&video_url(video))
|
||||
.extractor
|
||||
.video(video)
|
||||
.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 mut track = entry_to_track(&entry, parent);
|
||||
// The entry's id names the video; the caller's path is canonical.
|
||||
|
|
@ -428,14 +408,14 @@ impl ProviderClient for Client {
|
|||
}
|
||||
YtPath::Playlists => {
|
||||
if !self.logged_in {
|
||||
warn!(path, "playlists need a configured cookies file");
|
||||
warn!(path, "playlists need a working cookie login");
|
||||
return Err(ProviderError::MalformedPath);
|
||||
}
|
||||
self.playlists_node(path, parent).await?
|
||||
}
|
||||
YtPath::Playlist(playlist_id) => {
|
||||
if !self.logged_in {
|
||||
warn!(path, "playlists need a configured cookies file");
|
||||
warn!(path, "playlists need a working cookie login");
|
||||
return Err(ProviderError::MalformedPath);
|
||||
}
|
||||
self.playlist_node(path, playlist_id, parent).await?
|
||||
|
|
@ -517,107 +497,137 @@ impl ProviderClient for Client {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use extract::PlaylistEntry;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A fake yt-dlp: a shell script dispatching on its argv. Every test
|
||||
/// engine call goes through it — no network, no real binary.
|
||||
fn fake_binary(dir: &Path, body: &str) -> PathBuf {
|
||||
let path = dir.join("fake-yt-dlp");
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake binary");
|
||||
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&path, perms).expect("chmod");
|
||||
path
|
||||
/// A programmable extractor: provider logic is tested without
|
||||
/// network (architecture/youtube-rustypipe.md D1).
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeExtractor {
|
||||
/// query → entries (the limit is applied like the real one).
|
||||
searches: HashMap<String, Vec<VideoEntry>>,
|
||||
videos: HashMap<String, VideoEntry>,
|
||||
streams: HashMap<String, String>,
|
||||
playlists: Vec<PlaylistEntry>,
|
||||
playlist_videos: HashMap<String, (String, Vec<VideoEntry>)>,
|
||||
}
|
||||
|
||||
/// The standard fake: search terms `lofi` (2 results), one playlist
|
||||
/// feed, one playlist, and one resolvable video.
|
||||
const FAKE: &str = r#"
|
||||
case "$*" in
|
||||
*--version*) echo "2026.06.09"; exit 0 ;;
|
||||
*ytsearch2:lofi\ beats*) printf '%s' '{"entries":[{"id":"vidA","title":"Beats","uploader":"Chan"}]}'; exit 0 ;;
|
||||
*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"#;
|
||||
fn entry(id: &str, title: &str, artist: Option<&str>, duration: Option<u32>) -> VideoEntry {
|
||||
VideoEntry {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
artist: artist.map(str::to_string),
|
||||
duration,
|
||||
}
|
||||
}
|
||||
|
||||
async fn client_with(dir: &Path, body: &str, cookies: Option<&Path>) -> Client {
|
||||
let binary = fake_binary(dir, body);
|
||||
#[async_trait]
|
||||
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 {
|
||||
binary: Some(binary),
|
||||
cookies: cookies.map(Path::to_path_buf),
|
||||
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()
|
||||
};
|
||||
let toml = toml::to_string(&settings).expect("settings toml");
|
||||
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());
|
||||
Client::with_extractor(Box::new(extractor), settings, logged_in)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_lists_playlists_only_when_logged_in() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let anon = client(dir.path()).await;
|
||||
fn client() -> Client {
|
||||
client_with(fake(), false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_lists_playlists_only_when_logged_in() {
|
||||
let anon = client();
|
||||
let root = anon.get_lib_root();
|
||||
let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
|
||||
assert_eq!(titles, vec!["search"]);
|
||||
assert!(root.children[0].is_creatable, "search is creatable");
|
||||
|
||||
let cookies = dir.path().join("cookies.txt");
|
||||
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 logged_in = client_with(fake(), true);
|
||||
let root = logged_in.get_lib_root();
|
||||
let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
|
||||
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]
|
||||
async fn search_terms_are_created_listed_and_searched() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let client = client(dir.path()).await;
|
||||
let client = client();
|
||||
let node = client
|
||||
.create_lib_node("/youtube/search", "lofi")
|
||||
.await
|
||||
|
|
@ -625,13 +635,13 @@ esac"#;
|
|||
assert_eq!(node.path, "/youtube/search/lofi");
|
||||
assert!(node.is_queable, "pure track results are queueable");
|
||||
assert!(node.is_downloadable, "search results are downloadable");
|
||||
// The configured limit (2) caps the fixture's three results.
|
||||
assert_eq!(node.tracks.len(), 2);
|
||||
let one = &node.tracks[0];
|
||||
assert_eq!(one.path, "/youtube/search/lofi/vid1");
|
||||
assert_eq!(one.title, "Track One");
|
||||
assert_eq!(one.artist, "Chan");
|
||||
assert_eq!(one.duration, Some(63));
|
||||
// `channel` is an accepted alias for the artist.
|
||||
assert_eq!(node.tracks[1].artist, "Chan Two");
|
||||
|
||||
// The search node lists the term as an editable/deletable child.
|
||||
|
|
@ -657,8 +667,7 @@ esac"#;
|
|||
|
||||
#[tokio::test]
|
||||
async fn search_terms_rename_and_delete() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let client = client(dir.path()).await;
|
||||
let client = client();
|
||||
let _ = client
|
||||
.create_lib_node("/youtube/search", "lofi beats")
|
||||
.await
|
||||
|
|
@ -689,37 +698,32 @@ esac"#;
|
|||
|
||||
#[tokio::test]
|
||||
async fn playlists_list_and_resolve_when_logged_in() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
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 logged_in = client_with(fake(), true);
|
||||
|
||||
let playlists = client
|
||||
let playlists = logged_in
|
||||
.get_lib_node("/youtube/playlists")
|
||||
.await
|
||||
.expect("playlists feed");
|
||||
.expect("saved playlists");
|
||||
assert_eq!(playlists.children.len(), 1);
|
||||
let pl = &playlists.children[0];
|
||||
assert_eq!(pl.path, "/youtube/playlists/PL1");
|
||||
assert_eq!(pl.title, "Road Mix");
|
||||
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!(node.is_queable && node.is_downloadable);
|
||||
assert_eq!(node.tracks.len(), 1);
|
||||
assert_eq!(node.tracks[0].path, "/youtube/playlists/PL1/vid9");
|
||||
|
||||
// Logged out, the playlists subtree is a malformed path.
|
||||
let dir2 = TempDir::new().expect("tempdir");
|
||||
let anon = client_with(dir2.path(), FAKE, None).await;
|
||||
let anon = client();
|
||||
assert!(anon.get_lib_node("/youtube/playlists").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tracks_resolve_streams_and_metadata() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let client = client(dir.path()).await;
|
||||
let client = client();
|
||||
assert!(client.is_track_path("/youtube/search/lofi/vid1"));
|
||||
assert!(client.is_track_path("/youtube/playlists/PL1/vid9"));
|
||||
assert!(!client.is_track_path("/youtube/search/lofi"));
|
||||
|
|
@ -727,14 +731,8 @@ esac"#;
|
|||
let urls = client
|
||||
.get_urls_for_track("/youtube/search/lofi/vid1")
|
||||
.await
|
||||
.expect("stream urls");
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"https://example.test/a.webm".to_string(),
|
||||
"https://example.test/b.webm".into()
|
||||
]
|
||||
);
|
||||
.expect("stream url");
|
||||
assert_eq!(urls, vec!["https://example.test/a.m4a".to_string()]);
|
||||
let track = client
|
||||
.get_metadata_for_track("/youtube/playlists/PL1/vid1")
|
||||
.await
|
||||
|
|
@ -746,62 +744,36 @@ esac"#;
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_failures_are_typed_never_panics() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
// Non-zero exit on everything but the probe.
|
||||
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;
|
||||
async fn extractor_failures_are_typed_never_panics() {
|
||||
// An empty fake answers nothing: every lookup is a typed error.
|
||||
let client = client_with(FakeExtractor::default(), true);
|
||||
assert!(client.get_lib_node("/youtube/search/lofi").await.is_err());
|
||||
assert!(client
|
||||
.get_urls_for_track("/youtube/search/lofi/vid1")
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
// Malformed JSON is a typed error, not a panic.
|
||||
let dir2 = TempDir::new().expect("tempdir");
|
||||
let body = r#"
|
||||
case "$*" in
|
||||
*--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"
|
||||
);
|
||||
assert!(client
|
||||
.get_metadata_for_track("/youtube/search/lofi/vid1")
|
||||
.await
|
||||
.is_err());
|
||||
assert!(client.get_lib_node("/youtube/playlists/PL1").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_and_malformed_paths_are_rejected() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let client = client(dir.path()).await;
|
||||
let client = client();
|
||||
for path in ["/tidal/artists", "/youtube/nope", "/youtube/search/a/b/c"] {
|
||||
assert!(client.get_lib_node(path).await.is_err(), "{path}");
|
||||
}
|
||||
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