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:
Test User 2026-07-21 17:12:40 +02:00
parent 214dece19f
commit 6f787285bf
12 changed files with 1155 additions and 458 deletions

372
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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`:

View File

@ -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.

View File

@ -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
{ {

View File

@ -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

29
plan/youtube-rustypipe.md Normal file
View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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)
}
}

305
ytdy/src/extract.rs Normal file
View File

@ -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);
}
}

View File

@ -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));
}
} }