diff --git a/Cargo.lock b/Cargo.lock index efff58a..00b0ae4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index e6422d7..469aac0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/architecture/youtube-provider.md b/architecture/youtube-provider.md index 54f0425..20208dc 100644 --- a/architecture/youtube-provider.md +++ b/architecture/youtube-provider.md @@ -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`: diff --git a/architecture/youtube-rustypipe.md b/architecture/youtube-rustypipe.md new file mode 100644 index 0000000..6eec1b2 --- /dev/null +++ b/architecture/youtube-rustypipe.md @@ -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 +`/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. diff --git a/devenv.nix b/devenv.nix index 027f6b6..48582d6 100644 --- a/devenv.nix +++ b/devenv.nix @@ -14,8 +14,6 @@ let d2 pkg-config protobuf - # Extraction engine for the ytdy provider (architecture/youtube-provider.md D1). - yt-dlp ]; in { diff --git a/plan/summary.md b/plan/summary.md index 95e89dc..f670341 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -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 +`/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 diff --git a/plan/youtube-rustypipe.md b/plan/youtube-rustypipe.md new file mode 100644 index 0000000..3660eb6 --- /dev/null +++ b/plan/youtube-rustypipe.md @@ -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`. 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. diff --git a/quality/youtube-rustypipe.md b/quality/youtube-rustypipe.md new file mode 100644 index 0000000..c8360fb --- /dev/null +++ b/quality/youtube-rustypipe.md @@ -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 `/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. diff --git a/ytdy/Cargo.toml b/ytdy/Cargo.toml index 519e3bd..39da9a3 100644 --- a/ytdy/Cargo.toml +++ b/ytdy/Cargo.toml @@ -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 diff --git a/ytdy/src/engine.rs b/ytdy/src/engine.rs deleted file mode 100644 index 2d57845..0000000 --- a/ytdy/src/engine.rs +++ /dev/null @@ -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, - /// 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, - #[serde(default)] - pub channel: Option, - /// Seconds; yt-dlp emits floats. - #[serde(default)] - pub duration: Option, -} - -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, - #[serde(default)] - pub entries: Vec, -} - -/// 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, - timeout: Duration, -} - -impl Engine { - /// An engine invoking `binary` with `timeout` per call, passing - /// `--cookies ` on every call when set. - pub fn new(binary: PathBuf, cookies: Option, 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 { - 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 { - 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 { - 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, 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, 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) - } -} diff --git a/ytdy/src/extract.rs b/ytdy/src/extract.rs new file mode 100644 index 0000000..7d9285d --- /dev/null +++ b/ytdy/src/extract.rs @@ -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, + /// Duration in seconds; `None` for livestreams. + pub duration: Option, +} + +/// 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, ExtractError>; + /// Metadata of one video. + async fn video(&self, id: &str) -> Result; + /// 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; + /// The logged-in user's saved playlists (first page). + async fn saved_playlists(&self) -> Result, ExtractError>; + /// One playlist's name and up to `limit` videos (bounded + /// pagination). + async fn playlist_videos( + &self, + id: &str, + limit: usize, + ) -> Result<(String, Vec), 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 { + 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 { + 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, ExtractError> { + let result = self + .rp + .query() + .search::(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 { + // `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 { + 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, 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), 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); + } +} diff --git a/ytdy/src/lib.rs b/ytdy/src/lib.rs index 2887eb9..bf47426 100644 --- a/ytdy/src/lib.rs +++ b/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, - /// 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, /// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`]. pub search_results: Option, - /// 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, } @@ -90,27 +94,21 @@ fn parse_path(path: &str) -> Result, 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, 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:` flat listing). Queueable and downloadable — - /// results are homogeneous tracks. + /// A client over any extractor — the seam the tests use. + fn with_extractor(extractor: Box, 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 { - 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 { - 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 + /// `/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 { 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, 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 { 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>, + videos: HashMap, + streams: HashMap, + playlists: Vec, + playlist_videos: HashMap)>, } - /// 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) -> 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, 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 { + 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 { + self.streams.get(id).cloned().ok_or(ExtractError::NoAudio) + } + async fn saved_playlists(&self) -> Result, ExtractError> { + Ok(self.playlists.clone()) + } + async fn playlist_videos( + &self, + id: &str, + limit: usize, + ) -> Result<(String, Vec), 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)); + } }