Compare commits

..

17 commits

Author SHA1 Message Date
83f8652776 ci: disable renovate 2025-02-22 23:02:15 +00:00
739eac4d1f
test: fix tests 2025-02-18 00:16:09 +01:00
4d60e64f2c
ci: remove workflow_dispatch trigger 2025-02-09 04:35:30 +01:00
45d3a9cd33
ci: add CLI release files 2025-02-09 03:57:13 +01:00
f8a0a253cc
change line in downloader changelog 2025-02-09 03:15:30 +01:00
8933c6fa2a
chore(release): release rustypipe-cli v0.7.0 2025-02-09 03:14:30 +01:00
629b5905da
feat: add verbose flag 2025-02-09 03:09:47 +01:00
26e0c2cb2b
chore(release): release rustypipe-downloader v0.3.0 2025-02-09 02:53:59 +01:00
fb1b732d56
chore(release): release rustypipe v0.10.0 2025-02-09 02:32:44 +01:00
80a358ee54
Revert "refactor!: rename n_http_retries option to n_request_attempts to be less misleading"
This reverts commit b8cfe1b034.
2025-02-09 02:20:55 +01:00
c0770f281c
ci: release rustypipe-cli binaries 2025-02-09 02:01:27 +01:00
1d755b76bf
feat: add RustyPipe::version_botguard fn, detect rustypipe-botguard in current dir, add botguard version to report 2025-02-09 01:52:09 +01:00
9957add2b5
doc: add Botguard info to README 2025-02-07 23:15:34 +01:00
c1a872e1c1
refactor: rename rustypipe-cli binary name to rustypipe 2025-02-07 22:50:56 +01:00
0c94267d03
fix: only use cached potokens with min. 10min lifetime 2025-02-07 22:01:59 +01:00
a80f046a19
ci: update rustypipe-botguard 2025-02-07 20:46:33 +01:00
65cb4244c6
feat!: add userdata feature for all personal data queries (playback history, subscriptions) 2025-02-07 13:21:12 +01:00
46 changed files with 608 additions and 258 deletions

View file

@ -28,17 +28,22 @@ jobs:
run: |
TARGET=$(rustc --version --verbose | grep "host:" | sed -e 's/^host: //')
cd ~
curl -SsL -o rustypipe-botguard.tar.xz "https://codeberg.org/ThetaDev/rustypipe-botguard/releases/download/v0.1.0/rustypipe-botguard-v0.1.0-${TARGET}.tar.xz"
curl -SsL -o rustypipe-botguard.tar.xz "https://codeberg.org/ThetaDev/rustypipe-botguard/releases/download/v0.1.1/rustypipe-botguard-v0.1.1-${TARGET}.tar.xz"
cd /usr/local/bin
sudo tar -xJf ~/rustypipe-botguard.tar.xz
rm ~/rustypipe-botguard.tar.xz
rustypipe-botguard --version
- name: 📎 Clippy
run: cargo clippy --all --tests --features=rss,indicatif,audiotag -- -D warnings
run: |
cargo clippy --all --tests --features=rss,userdata,indicatif,audiotag -- -D warnings
cargo clippy --package=rustypipe --tests -- -D warnings
cargo clippy --package=rustypipe-downloader -- -D warnings
cargo clippy --package=rustypipe-cli -- -D warnings
cargo clippy --package=rustypipe-cli --features=timezone -- -D warnings
- name: 🧪 Test
run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 --features rss --workspace -- --skip 'cookie_auth::'
run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 --features rss,userdata --workspace -- --skip 'user_data::'
env:
ALL_PROXY: "http://warpproxy:8124"

View file

@ -0,0 +1,69 @@
name: Release CLI
on:
push:
tags:
- "rustypipe-cli/v*.*.*"
jobs:
Release:
runs-on: cimaster-latest
steps:
- name: 📦 Checkout repository
uses: actions/checkout@v4
- name: Setup cross compilation
run: |
rustup target add x86_64-pc-windows-msvc x86_64-apple-darwin aarch64-apple-darwin
cargo install cargo-xwin
# https://wapl.es/rust/2019/02/17/rust-cross-compile-linux-to-macos.html/
sudo apt-get install -y llvm clang cmake
cd ~
git clone https://github.com/tpoechtrager/osxcross
cd osxcross
wget -nc "https://github.com/joseluisq/macosx-sdks/releases/download/12.3/MacOSX12.3.sdk.tar.xz"
mv MacOSX12.3.sdk.tar.xz tarballs/
UNATTENDED=yes OSX_VERSION_MIN=12.3 ./build.sh
OSXCROSS_BIN="$(pwd)/target/bin"
echo "CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=$(find "$OSXCROSS_BIN" -name "x86_64-apple-darwin*-clang")" >> $GITHUB_ENV
echo "CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=-Car=$(find "$OSXCROSS_BIN" -name "x86_64-apple-darwin*-ar"),-Clink-arg=-undefined,-Clink-arg=dynamic_lookup" >> $GITHUB_ENV
echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=$(find "$OSXCROSS_BIN" -name "aarch64-apple-darwin*-clang")" >> $GITHUB_ENV
echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=-Car=$(find "$OSXCROSS_BIN" -name "aarch64-apple-darwin*-ar"),-Clink-arg=-undefined,-Clink-arg=dynamic_lookup" >> $GITHUB_ENV
- name: ⚒️ Build application
run: |
export PATH="$PATH:$HOME/osxcross/target/bin"
CRATE="rustypipe-cli"
PKG_CONFIG_SYSROOT_DIR=/usr/x86_64-linux-gnu cargo build --release --package=$CRATE --target x86_64-unknown-linux-gnu
PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu cargo build --release --package=$CRATE --target aarch64-unknown-linux-gnu
CC="$CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER" CXX="$CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER++" cargo build --release --package=$CRATE --target x86_64-apple-darwin
CC="$CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER" CXX="$CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER++" cargo build --release --package=$CRATE --target aarch64-apple-darwin
cargo xwin build --release --package=$CRATE --target x86_64-pc-windows-msvc
- name: Prepare release
run: |
CRATE="rustypipe-cli"
BIN="rustypipe"
echo "CRATE=$CRATE" >> "$GITHUB_ENV"
echo "CRATE_VERSION=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==2{print}')" >> "$GITHUB_ENV"
CL_PATH="cli/CHANGELOG.md"
{
echo 'CHANGELOG<<END_OF_FILE'
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CL_PATH"
echo END_OF_FILE
} >> "$GITHUB_ENV"
mkdir dist
for arch in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin; do
tar -cJf "dist/${BIN}-${CRATE_VERSION}-${arch}.tar.xz" -C target/${arch}/release "${BIN}"
done
(cd target/x86_64-pc-windows-msvc/release && zip -9 "../../../dist/${BIN}-${CRATE_VERSION}-x86_64-pc-windows-msvc.zip" "${BIN}.exe")
- name: 🎉 Publish release
uses: https://gitea.com/actions/release-action@main
with:
title: "${{ env.CRATE }} ${{ env.CRATE_VERSION }}"
body: "${{ env.CHANGELOG }}"
files: dist/*

View file

@ -10,4 +10,8 @@ repos:
hooks:
- id: cargo-fmt
- id: cargo-clippy
args: ["--all", "--tests", "--features=rss,indicatif,audiotag", "--", "-D", "warnings"]
name: cargo-clippy rustypipe
args: ["--package=rustypipe", "--tests", "--", "-D", "warnings"]
- id: cargo-clippy
name: cargo-clippy workspace
args: ["--all", "--tests", "--features=rss,userdata,indicatif,audiotag", "--", "-D", "warnings"]

View file

@ -1,10 +0,0 @@
steps:
test:
image: rust:latest
environment:
- CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
commands:
- rustup component add rustfmt clippy
- cargo fmt --all --check
- cargo clippy --all --features=rss -- -D warnings
- cargo test --features=rss --workspace

View file

@ -3,6 +3,44 @@
All notable changes to this project will be documented in this file.
## [v0.10.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.9.0..rustypipe/v0.10.0) - 2025-02-09
### 🚀 Features
- Add visitor data cache, remove random visitor data - ([b12f4c5](https://codeberg.org/ThetaDev/rustypipe/commit/b12f4c5d821a9189d7ed8410ad860824b6d052ef))
- Add support for rustypipe-botguard to get PO tokens - ([b90a252](https://codeberg.org/ThetaDev/rustypipe/commit/b90a252a5e1bf05a5294168b0ec16a73cbb88f42))
- Add session po token cache - ([b72b501](https://codeberg.org/ThetaDev/rustypipe/commit/b72b501b6dbcf4333b24cd80e7c8c61b0c21ec91))
- Check rustypipe-botguard-api version - ([8385b87](https://codeberg.org/ThetaDev/rustypipe/commit/8385b87c63677f32a240679a78702f53072e517a))
- Rewrite request attempt system, retry with different visitor data - ([dfd03ed](https://codeberg.org/ThetaDev/rustypipe/commit/dfd03edfadff2657e9cfbf04e5d313ba409520ac))
- Log failed player fetch attempts with player_from_clients - ([8e35358](https://codeberg.org/ThetaDev/rustypipe/commit/8e35358c8941301f6ebf7646a11ab22711082569))
- Add timezone query option - ([3a2370b](https://codeberg.org/ThetaDev/rustypipe/commit/3a2370b97ca3d0f40d72d66a23295557317d29fb))
- [**breaking**] Add userdata feature for all personal data queries (playback history, subscriptions) - ([65cb424](https://codeberg.org/ThetaDev/rustypipe/commit/65cb4244c6ab547f53d0cb12af802c4189188c86))
- Add RustyPipe::version_botguard fn, detect rustypipe-botguard in current dir, add botguard version to report - ([1d755b7](https://codeberg.org/ThetaDev/rustypipe/commit/1d755b76bf4569f7d0bb90a65494ac8e7aae499a))
### 🐛 Bug Fixes
- Parsing history dates - ([af7dc10](https://codeberg.org/ThetaDev/rustypipe/commit/af7dc1016322a87dd8fec0b739939c2b12b6f400))
- A/V streams incorrectly recognized as video-only - ([2b891ca](https://codeberg.org/ThetaDev/rustypipe/commit/2b891ca0788f91f16dbb9203191cb3d2092ecc74))
- Update iOS client - ([e915416](https://codeberg.org/ThetaDev/rustypipe/commit/e91541629d6c944c1001f5883e3c1264aeeb3969))
- A/B test 20: music continuation item renderer - ([9c67f8f](https://codeberg.org/ThetaDev/rustypipe/commit/9c67f8f85bef8214848dc9d17bff6cff252e015e))
- Include whole request body in report - ([15245c1](https://codeberg.org/ThetaDev/rustypipe/commit/15245c18b584e42523762b94fcc7284d483660a0))
- Extracting nsig fn when outside variable starts with $ - ([eda16e3](https://codeberg.org/ThetaDev/rustypipe/commit/eda16e378730a3b57c4982a626df1622a93c574a))
- Retry updating deobf data after a RustyPipe update - ([50ab1f7](https://codeberg.org/ThetaDev/rustypipe/commit/50ab1f7a5d8aeaa3720264b4a4b27805bb0e8121))
- Allow player data to be fetched without botguard - ([29c854b](https://codeberg.org/ThetaDev/rustypipe/commit/29c854b20d7a6677415b1744e7ba7ecd4f594ea5))
- Output full request body in reports, clean up `get_player_po_token` - ([a0d850f](https://codeberg.org/ThetaDev/rustypipe/commit/a0d850f8e01428a73bbd66397d0dbf797b45958f))
- Correct timezone offset for parsed dates, add timezone_local option - ([a5a7be5](https://codeberg.org/ThetaDev/rustypipe/commit/a5a7be5b4e0a0b73d7e1dc802ebd7bd48dafc76d))
- Use localzone crate to get local tz - ([5acbf0e](https://codeberg.org/ThetaDev/rustypipe/commit/5acbf0e456b1f10707e0a56125d993a8129eee3a))
- Only use cached potokens with min. 10min lifetime - ([0c94267](https://codeberg.org/ThetaDev/rustypipe/commit/0c94267d0371b2b26c7b5c9abfa156d5cde2153e))
### 📚 Documentation
- Add Botguard info to README - ([9957add](https://codeberg.org/ThetaDev/rustypipe/commit/9957add2b5d6391b2c1869d2019fd7dd91b8cd41))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rquickjs to 0.9.0 (#33) - ([2c8ac41](https://codeberg.org/ThetaDev/rustypipe/commit/2c8ac410aa535d83f8bcc7181f81914b13bceb77))
## [v0.9.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.8.0..rustypipe/v0.9.0) - 2025-01-16
### 🚀 Features

View file

@ -1,6 +1,6 @@
[package]
name = "rustypipe"
version = "0.9.0"
version = "0.10.0"
rust-version = "1.67.1"
edition.workspace = true
authors.workspace = true
@ -40,7 +40,7 @@ serde_with = { version = "3.0.0", default-features = false, features = [
] }
serde_plain = "1.0.0"
sha1 = "0.10.0"
rand = "0.9.0"
rand = "0.8.0"
time = { version = "0.3.37", features = [
"macros",
"serde-human-readable",
@ -74,8 +74,8 @@ path_macro = "1.0.0"
tracing-test = "0.2.5"
# Included crates
rustypipe = { path = ".", version = "0.9.0", default-features = false }
rustypipe-downloader = { path = "./downloader", version = "0.2.1", default-features = false, features = [
rustypipe = { path = ".", version = "0.10.0", default-features = false }
rustypipe-downloader = { path = "./downloader", version = "0.3.0", default-features = false, features = [
"indicatif",
"audiotag",
] }
@ -84,6 +84,7 @@ rustypipe-downloader = { path = "./downloader", version = "0.2.1", default-featu
default = ["default-tls"]
rss = ["dep:quick-xml"]
userdata = []
# Reqwest TLS options
default-tls = ["reqwest/default-tls"]
@ -126,6 +127,6 @@ tracing-test.workspace = true
[package.metadata.docs.rs]
# To build locally:
# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features rss --no-deps --open
features = ["rss"]
# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features rss,userdata --no-deps --open
features = ["rss", "userdata"]
rustdoc-args = ["--cfg", "docsrs"]

View file

@ -1,19 +1,19 @@
test:
# cargo test --features=rss
cargo nextest run --workspace --features=rss --no-fail-fast --retries 1 -- --skip 'cookie_auth::'
# cargo test --features=rss,userdata
cargo nextest run --workspace --features=rss,userdata --no-fail-fast --retries 1 -- --skip 'user_data::'
unittest:
cargo nextest run --features=rss --no-fail-fast --lib
cargo nextest run --features=rss,userdata --no-fail-fast --lib
testyt:
cargo nextest run --features=rss --no-fail-fast --retries 1 --test youtube -- --skip 'cookie_auth::'
cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube -- --skip 'user_data::'
testyt-cookie:
cargo nextest run --features=rss --no-fail-fast --retries 1 --test youtube
cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube
testyt-localized:
YT_LANG=th cargo nextest run --features=rss --no-fail-fast --retries 1 --test youtube -- \
--skip 'cookie_auth::' --skip 'search_suggestion' --skip 'isrc_search_languages'
YT_LANG=th cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube -- \
--skip 'user_data::' --skip 'search_suggestion' --skip 'isrc_search_languages'
testintl:
#!/usr/bin/env bash
@ -33,7 +33,7 @@ testintl:
echo "---TESTS FOR $YT_LANG ---"
if YT_LANG="$YT_LANG" cargo nextest run --no-fail-fast --retries 1 --test-threads 4 --test youtube -- \
--skip 'cookie_auth::' --skip 'search_suggestion' --skip 'isrc_search_languages' --skip 'resolve_'; then
--skip 'user_data::' --skip 'search_suggestion' --skip 'isrc_search_languages' --skip 'resolve_'; then
echo "--- $YT_LANG COMPLETED ---"
else
echo "--- $YT_LANG FAILED ---"

View file

@ -181,6 +181,19 @@ Subscribers: 1780000
...
```
## Crate features
Some features of RustyPipe are gated behind features to avoid compiling unneeded
dependencies.
- `rss` Fetch a channel's RSS feed, which is faster than fetching the channel page
- `userdata` Add functions to fetch YouTube user data (watch history, subscriptions,
music library)
You can also choose the TLS library used for making web requests using the same features
as the reqwest crate (`default-tls`, `native-tls`, `native-tls-alpn`,
`native-tls-vendored`, `rustls-tls-webpki-roots`, `rustls-tls-native-roots`).
## Cache storage
The RustyPipe cache holds the current version numbers for all clients, the JavaScript
@ -213,6 +226,21 @@ RustyPipe reports come in 3 severity levels:
incomplete)
- ERR (entire response could not be deserialized/parsed, RustyPipe returned an error)
## PO tokens
Since August 2024 YouTube requires PO tokens to access streams from web-based clients
(Desktop, Mobile). Otherwise streams will return a 403 error.
Generating PO tokens requires a simulated browser environment, which would be too large
to include in RustyPipe directly.
Therefore, the PO token generation is handled by a seperate CLI application
([rustypipe-botguard](https://codeberg.org/ThetaDev/rustypipe-botguard)) which is called
by the RustyPipe crate. RustyPipe automatically detects the rustypipe-botguard binary if
it is located in PATH or the current working directory. If your rustypipe-botguard
binary is located at a different path, you can specify it with the `.botguard_bin(path)`
option.
## Authentication
RustyPipe supports authenticating with your YouTube account to access

View file

@ -3,6 +3,36 @@
All notable changes to this project will be documented in this file.
## [v0.7.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.6.0..rustypipe-cli/v0.7.0) - 2025-02-09
### 🚀 Features
- Add support for rustypipe-botguard to get PO tokens - ([b90a252](https://codeberg.org/ThetaDev/rustypipe/commit/b90a252a5e1bf05a5294168b0ec16a73cbb88f42))
- [**breaking**] Remove manual PO token options from downloader/cli, add new rustypipe-botguard options - ([cddb32f](https://codeberg.org/ThetaDev/rustypipe/commit/cddb32f190276265258c6ab45b3d43a8891c4b39))
- Add session po token cache - ([b72b501](https://codeberg.org/ThetaDev/rustypipe/commit/b72b501b6dbcf4333b24cd80e7c8c61b0c21ec91))
- Add timezone query option - ([3a2370b](https://codeberg.org/ThetaDev/rustypipe/commit/3a2370b97ca3d0f40d72d66a23295557317d29fb))
- Add --timezone-local CLI option - ([4f2bb47](https://codeberg.org/ThetaDev/rustypipe/commit/4f2bb47ab42ae0c68a64f3b3c2831fa7850b6f56))
- Add verbose flag - ([629b590](https://codeberg.org/ThetaDev/rustypipe/commit/629b5905da653c6fe0f3c6b5814dd2f49030e7ed))
### 🐛 Bug Fixes
- Parsing mixed-case language codes like zh-CN - ([9c73ed4](https://codeberg.org/ThetaDev/rustypipe/commit/9c73ed4b3008cb093c0fa7fd94fd9f1ba8cd3627))
### 🚜 Refactor
- [**breaking**] Add client_type field to DownloadError, rename cli option po-token-cache to pot-cache - ([594e675](https://codeberg.org/ThetaDev/rustypipe/commit/594e675b39efc5fbcdbd5e920a4d2cdee64f718e))
- Rename rustypipe-cli binary to rustypipe - ([c1a872e](https://codeberg.org/ThetaDev/rustypipe/commit/c1a872e1c14ea0956053bd7c65f6875b1cb3bc55))
### 📚 Documentation
- Add Botguard info to README - ([9957add](https://codeberg.org/ThetaDev/rustypipe/commit/9957add2b5d6391b2c1869d2019fd7dd91b8cd41))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rustypipe to 0.10.0
- *(deps)* Update rust crate rquickjs to 0.9.0 (#33) - ([2c8ac41](https://codeberg.org/ThetaDev/rustypipe/commit/2c8ac410aa535d83f8bcc7181f81914b13bceb77))
## [v0.6.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.5.0..rustypipe-cli/v0.6.0) - 2025-01-16
### 🚀 Features

View file

@ -1,6 +1,6 @@
[package]
name = "rustypipe-cli"
version = "0.6.0"
version = "0.7.0"
rust-version = "1.70.0"
edition.workspace = true
authors.workspace = true
@ -42,7 +42,7 @@ rustls-tls-native-roots = [
]
[dependencies]
rustypipe = { workspace = true, features = ["rss"] }
rustypipe = { workspace = true, features = ["rss", "userdata"] }
rustypipe-downloader.workspace = true
reqwest.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
@ -64,3 +64,7 @@ dirs.workspace = true
anstream = "0.6.15"
owo-colors = "4.0.0"
const_format = "0.2.33"
[[bin]]
name = "rustypipe"
path = "src/main.rs"

View file

@ -8,7 +8,19 @@ The RustyPipe CLI is a powerful YouTube client for the command line. It allows y
access most of the features of the RustyPipe crate: getting data from YouTube and
downloading videos.
The following subcommands are included:
## Installation
You can download a compiled version of RustyPipe here:
<https://codeberg.org/ThetaDev/rustypipe/releases>
Alternatively, you can compile it yourself by installing [Rust](https://rustup.rs/) and
running `cargo install rustypipe-cli`.
To be able to access streams from web-based clients (Desktop, Mobile) you need to
download [rustypipe-botguard](https://codeberg.org/ThetaDev/rustypipe-botguard/releases)
and place the binary either in the PATH or the current working directory.
For downloading videos you also need to have ffmpeg installed.
## `get`: Fetch information
@ -127,8 +139,8 @@ Fetch a list of all the items saved in your YouTube/YouTube Music profile.
- **Proxy:** RustyPipe respects the environment variables `HTTP_PROXY`, `HTTPS_PROXY`
and `ALL_PROXY`
- **Logging:** You can change the log level with the `RUST_LOG` environment variable, it
is set to `info` by default
- **Logging:** Enable debug logging with the `-v` (verbose) flag. If you want more
fine-grained control, use the `RUST_LOG` environment variable.
- **Visitor data:** A custom visitor data ID can be used with the `--vdata` flag
- **Authentication:** Use the commands `rustypipe login` and `rustypipe login --cookie`
to log into your Google account using either OAuth or YouTube cookies. With the
@ -140,6 +152,7 @@ Fetch a list of all the items saved in your YouTube/YouTube Music profile.
Europe/Berlin, Australia/Sydney)
**Note:** this requires building rustypipe-cli with the `timezone` feature
- `--local-tz` Use the local timezone instead of UTC
- `--report` Generate a report on every request and store it in a `rustypipe_reports`
folder in the current directory

View file

@ -80,6 +80,9 @@ struct Cli {
/// Enable caching for session-bound PO tokens
#[clap(long, global = true)]
pot_cache: bool,
/// Enable debug logging
#[clap(short, long, global = true)]
verbose: bool,
}
#[derive(Parser)]
@ -878,12 +881,15 @@ async fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
let multi = MultiProgress::new();
tracing_subscriber::fmt::SubscriberBuilder::default()
.with_env_filter(
EnvFilter::builder()
let mut env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.from_env_lossy();
if cli.verbose {
env_filter = env_filter.add_directive("rustypipe=debug".parse().unwrap());
}
tracing_subscriber::fmt::SubscriberBuilder::default()
.with_env_filter(env_filter)
.with_writer(ProgWriter(multi.clone()))
.init();

View file

@ -9,7 +9,7 @@ repository.workspace = true
publish = false
[dependencies]
rustypipe = { path = "../" }
rustypipe = { path = "../", features = ["userdata"] }
reqwest.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread"] }
futures-util.workspace = true

View file

@ -39,9 +39,6 @@ pub async fn download_testfiles() {
search_playlists().await;
search_empty().await;
trending().await;
history().await;
subscriptions().await;
subscription_feed().await;
music_playlist().await;
music_playlist_cont().await;
@ -65,6 +62,12 @@ pub async fn download_testfiles() {
music_charts().await;
music_genres().await;
music_genre().await;
// User data
history().await;
subscriptions().await;
subscription_feed().await;
music_history().await;
music_saved_artists().await;
music_saved_albums().await;
@ -464,7 +467,7 @@ async fn trending() {
}
async fn history() {
let json_path = path!(*TESTFILES_DIR / "history" / "history.json");
let json_path = path!(*TESTFILES_DIR / "userdata" / "history.json");
if json_path.exists() {
return;
}
@ -474,7 +477,7 @@ async fn history() {
}
async fn subscriptions() {
let json_path = path!(*TESTFILES_DIR / "history" / "subscriptions.json");
let json_path = path!(*TESTFILES_DIR / "userdata" / "subscriptions.json");
if json_path.exists() {
return;
}
@ -484,7 +487,7 @@ async fn subscriptions() {
}
async fn subscription_feed() {
let json_path = path!(*TESTFILES_DIR / "history" / "subscription_feed.json");
let json_path = path!(*TESTFILES_DIR / "userdata" / "subscription_feed.json");
if json_path.exists() {
return;
}
@ -816,7 +819,7 @@ async fn music_genre() {
}
async fn music_history() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "music_history.json");
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "music_history.json");
if json_path.exists() {
return;
}
@ -826,7 +829,7 @@ async fn music_history() {
}
async fn music_saved_artists() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_artists.json");
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_artists.json");
if json_path.exists() {
return;
}
@ -836,7 +839,7 @@ async fn music_saved_artists() {
}
async fn music_saved_albums() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_albums.json");
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_albums.json");
if json_path.exists() {
return;
}
@ -846,7 +849,7 @@ async fn music_saved_albums() {
}
async fn music_saved_tracks() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_tracks.json");
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_tracks.json");
if json_path.exists() {
return;
}
@ -856,7 +859,7 @@ async fn music_saved_tracks() {
}
async fn music_saved_playlists() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_playlists.json");
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_playlists.json");
if json_path.exists() {
return;
}

View file

@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
## [v0.3.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.7..rustypipe-downloader/v0.3.0) - 2025-02-09
### 🚀 Features
- [**breaking**] Remove manual PO token options from downloader in favor of rustypipe-botguard - ([cddb32f](https://codeberg.org/ThetaDev/rustypipe/commit/cddb32f190276265258c6ab45b3d43a8891c4b39))
### 🐛 Bug Fixes
- Ensure downloader futures are send - ([812ff4c](https://codeberg.org/ThetaDev/rustypipe/commit/812ff4c5bafffc5708a6d5066f1ebadb6d9fc958))
- Download audio with dolby codec - ([9234005](https://codeberg.org/ThetaDev/rustypipe/commit/92340056f868007beccb64e9e26eb39abc40f7aa))
### 🚜 Refactor
- [**breaking**] Add client_type field to DownloadError, rename cli option po-token-cache to pot-cache - ([594e675](https://codeberg.org/ThetaDev/rustypipe/commit/594e675b39efc5fbcdbd5e920a4d2cdee64f718e))
### 📚 Documentation
- Add Botguard info to README - ([9957add](https://codeberg.org/ThetaDev/rustypipe/commit/9957add2b5d6391b2c1869d2019fd7dd91b8cd41))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rustypipe to 0.10.0
## [v0.2.7](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.6..rustypipe-downloader/v0.2.7) - 2025-01-16
### 🚀 Features

View file

@ -1,6 +1,6 @@
[package]
name = "rustypipe-downloader"
version = "0.2.7"
version = "0.3.0"
rust-version = "1.67.1"
edition.workspace = true
authors.workspace = true

View file

@ -1063,8 +1063,8 @@ impl DownloadQuery {
}
fn get_download_range(offset: u64, size: Option<u64>) -> Range<u64> {
let mut rng = rand::rng();
let chunk_size = rng.random_range(CHUNK_SIZE_MIN..CHUNK_SIZE_MAX);
let mut rng = rand::thread_rng();
let chunk_size = rng.gen_range(CHUNK_SIZE_MIN..CHUNK_SIZE_MAX);
let mut chunk_end = offset + chunk_size;
if let Some(size) = size {

View file

@ -3,7 +3,7 @@ use std::fmt::Debug;
use crate::{
error::{Error, ExtractionError},
model::ChannelRss,
report::{Report, RustyPipeInfo},
report::Report,
util,
};
@ -45,7 +45,7 @@ impl RustyPipeQuery {
Err(e) => {
if let Some(reporter) = &self.client.inner.reporter {
let report = Report {
info: RustyPipeInfo::new(Some(self.opts.lang)),
info: self.rp_info(),
level: crate::report::Level::ERR,
operation: "channel_rss",
error: Some(e.to_string()),

View file

@ -3,12 +3,10 @@
pub(crate) mod response;
mod channel;
mod history;
mod music_artist;
mod music_charts;
mod music_details;
mod music_genres;
mod music_history;
mod music_new;
mod music_playlist;
mod music_search;
@ -20,6 +18,13 @@ mod trends;
mod url_resolver;
mod video_details;
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
mod music_userdata;
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
mod userdata;
#[cfg(feature = "rss")]
#[cfg_attr(docsrs, doc(cfg(feature = "rss")))]
mod channel_rss;
@ -357,6 +362,8 @@ const OAUTH_CLIENT_ID: &str =
const OAUTH_CLIENT_SECRET: &str = "SboVhoG9s0rNafixCSGGKXAT";
const OAUTH_SCOPES: &str = "http://gdata.youtube.com https://www.googleapis.com/auth/youtube";
const BOTGUARD_API_VERSION: &str = "1";
static CLIENT_VERSION_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#""INNERTUBE_CONTEXT_CLIENT_VERSION":"([\w\d\._-]+?)""#).unwrap());
@ -374,7 +381,7 @@ struct RustyPipeRef {
http: Client,
storage: Option<Box<dyn CacheStorage>>,
reporter: Option<Box<dyn Reporter>>,
n_request_attempts: u32,
n_http_retries: u32,
cache: CacheHolder,
default_opts: RustyPipeOpts,
user_agent: Cow<'static, str>,
@ -398,17 +405,19 @@ struct RustyPipeOpts {
pub struct RustyPipeBuilder {
storage: DefaultOpt<Box<dyn CacheStorage>>,
reporter: DefaultOpt<Box<dyn Reporter>>,
n_request_attempts: u32,
n_http_retries: u32,
timeout: DefaultOpt<Duration>,
user_agent: Option<String>,
default_opts: RustyPipeOpts,
storage_dir: Option<PathBuf>,
botguard_bin: DefaultOpt<OsString>,
snapshot_file: Option<PathBuf>,
po_token_cache: bool,
}
struct BotguardCfg {
program: OsString,
version: String,
snapshot_file: PathBuf,
po_token_cache: bool,
}
@ -436,13 +445,6 @@ impl<T> DefaultOpt<T> {
DefaultOpt::Default => Some(f()),
}
}
fn or_default_opt<F: FnOnce() -> Option<T>>(self, f: F) -> Option<T> {
match self {
DefaultOpt::Some(x) => Some(x),
DefaultOpt::None => None,
DefaultOpt::Default => f(),
}
}
}
/// # RustyPipe query
@ -503,6 +505,26 @@ impl<T> DefaultOpt<T> {
/// - [`music_new_albums`](RustyPipeQuery::music_new_albums)
/// - [`music_new_videos`](RustyPipeQuery::music_new_videos)
///
/// ### User data (🔒 Feature `userdata`)
///
/// - **Playback history**
/// - [`history`](RustyPipeQuery::history)
/// - [`history_search`](RustyPipeQuery::history_search)
/// - [`music_history`](RustyPipeQuery::music_history)
/// - **YouTube library**
/// - [`liked_videos`](RustyPipeQuery::liked_videos)
/// - [`watch_later`](RustyPipeQuery::watch_later)
/// - [`saved_playlists`](RustyPipeQuery::saved_playlists)
/// - **Music library**
/// - [`music_saved_artists`](RustyPipeQuery::music_saved_artists)
/// - [`music_saved_albums`](RustyPipeQuery::music_saved_albums)
/// - [`music_saved_tracks`](RustyPipeQuery::music_saved_tracks)
/// - [`music_saved_playlists`](RustyPipeQuery::music_saved_playlists)
/// - [`music_liked_tracks`](RustyPipeQuery::music_liked_tracks)
/// - **Subscriptions**
/// - [`subscriptions`](RustyPipeQuery::subscriptions)
/// - [`subscription_feed`](RustyPipeQuery::subscription_feed)
///
/// ## Options
///
/// You can set the language, country and visitor data ID for individual requests.
@ -655,10 +677,11 @@ impl RustyPipeBuilder {
storage: DefaultOpt::Default,
reporter: DefaultOpt::Default,
timeout: DefaultOpt::Default,
n_request_attempts: 2,
n_http_retries: 2,
user_agent: None,
storage_dir: None,
botguard_bin: DefaultOpt::Default,
snapshot_file: None,
po_token_cache: false,
}
}
@ -724,27 +747,31 @@ impl RustyPipeBuilder {
let visitor_data_cache = VisitorDataCache::new(http.clone(), 50, 20);
let botguard_bin = self.botguard_bin.or_default_opt(|| {
let n = OsString::from("rustypipe-botguard");
let out = std::process::Command::new(&n)
.arg("--version")
.output()
.ok()?;
if !out.status.success() {
return None;
let botguard = match self.botguard_bin {
DefaultOpt::Some(botguard_bin) => Some(detect_botguard_bin(botguard_bin)?),
DefaultOpt::None => None,
DefaultOpt::Default => detect_botguard_bin("./rustypipe-botguard".into())
.or_else(|_| detect_botguard_bin("rustypipe-botguard".into()))
.map_err(|e| tracing::debug!("could not detect rustypipe-botguard: {e}"))
.ok(),
}
let output = String::from_utf8_lossy(&out.stdout);
let pat = "rustypipe-botguard-api ";
let pos = output.find(pat)? + pat.len();
let pos_end = output[pos..]
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map(|(p, _)| p + pos)
.unwrap_or(output.len());
if &output[pos..pos_end] != "1" {
return None;
.map(|(program, version)| {
tracing::debug!(
"rustypipe-botguard: using {} at {}",
version,
program.to_string_lossy()
);
BotguardCfg {
program: program.to_owned(),
version,
snapshot_file: self.snapshot_file.unwrap_or_else(|| {
let mut snapshot_file = storage_dir.clone();
snapshot_file.push("bg_snapshot.bin");
snapshot_file
}),
po_token_cache: self.po_token_cache,
}
Some(n)
});
Ok(RustyPipe {
@ -752,11 +779,11 @@ impl RustyPipeBuilder {
http,
storage,
reporter: self.reporter.or_default(|| {
let mut report_dir = storage_dir.clone();
let mut report_dir = storage_dir;
report_dir.push(DEFAULT_REPORT_DIR);
Box::new(FileReporter::new(report_dir))
}),
n_request_attempts: self.n_request_attempts,
n_http_retries: self.n_http_retries,
cache: CacheHolder {
clients: cache_clients,
deobf: AsyncRwLock::new(cdata.deobf),
@ -766,15 +793,7 @@ impl RustyPipeBuilder {
default_opts: self.default_opts,
user_agent,
visitor_data_cache,
botguard: botguard_bin.map(|program| {
let mut snapshot_file = storage_dir;
snapshot_file.push("bg_snapshot.bin");
BotguardCfg {
program,
snapshot_file,
po_token_cache: self.po_token_cache,
}
}),
botguard,
}),
})
}
@ -843,9 +862,9 @@ impl RustyPipeBuilder {
self
}
/// Set the maximum number of attempts for HTTP requests (at least 1).
/// Set the maximum number of retries for YouTube requests.
///
/// If a HTTP requests fails because of a serverside error and retries are enabled,
/// If a request fails because of a serverside error and retries are enabled,
/// RustyPipe waits 1 second before the next attempt.
///
/// The wait time is doubled for subsequent attempts (including a bit of
@ -853,8 +872,8 @@ impl RustyPipeBuilder {
///
/// **Default value**: 2
#[must_use]
pub fn n_request_attempts(mut self, n_retries: u32) -> Self {
self.n_request_attempts = n_retries.max(1);
pub fn n_http_retries(mut self, n_retries: u32) -> Self {
self.n_http_retries = n_retries.max(1);
self
}
@ -1010,6 +1029,18 @@ impl RustyPipeBuilder {
self
}
/// Set the path where the rustypipe-botguard snapshot file is stored
///
/// After solving a Botguard challenge, rustypipe-botguard stores its
/// JavaScript environment in a snapshot file, so it can quickly generate additional tokens.
///
/// By default the snapshot is stored in the storage_dir (Filename: bg_snapshot.bin).
#[must_use]
pub fn botguard_snapshot_file<P: Into<PathBuf>>(mut self, snapshot_file: P) -> Self {
self.snapshot_file = Some(snapshot_file.into());
self
}
/// Enable caching for session-bound PO tokens
///
/// By default, RustyPipe calls Botguard for every player request to fetch both a
@ -1060,7 +1091,7 @@ impl RustyPipe {
/// Execute the given http request.
async fn http_request(&self, request: &Request) -> Result<Response, reqwest::Error> {
let mut last_resp = None;
for n in 0..=self.inner.n_request_attempts {
for n in 0..=self.inner.n_http_retries {
let resp = self.inner.http.execute(request.try_clone().unwrap()).await;
let err = match resp {
@ -1086,7 +1117,7 @@ impl RustyPipe {
};
// Retry in case of a recoverable status code (server err, too many requests)
if n != self.inner.n_request_attempts {
if n != self.inner.n_http_retries {
let ms = util::retry_delay(n, 1000, 60000, 3);
tracing::warn!(
"Retry attempt #{}. Error: {}. Waiting {} ms",
@ -1674,6 +1705,11 @@ impl RustyPipe {
);
Ok(())
}
/// Get the version string (e.g. `rustypipe-botguard 0.1.1`) of the used botguard binary
pub async fn version_botguard(&self) -> Option<String> {
self.inner.botguard.as_ref().map(|bg| bg.version.to_owned())
}
}
impl RustyPipeQuery {
@ -2152,7 +2188,7 @@ impl RustyPipeQuery {
self.client.inner.visitor_data_cache.remove(visitor_data);
}
/// Get PO tokens
/// Generate PO tokens
async fn get_po_tokens(&self, idents: &[&str]) -> Result<(Vec<String>, OffsetDateTime), Error> {
let bg = self
.client
@ -2191,9 +2227,11 @@ impl RustyPipeQuery {
}
let mut valid_until = None;
let mut from_snapshot = false;
for word in words {
if let Some((k, v)) = word.split_once('=') {
if k == "valid_until" {
match k {
"valid_until" => {
valid_until = Some(
v.parse::<i64>()
.ok()
@ -2203,16 +2241,27 @@ impl RustyPipeQuery {
))?,
);
}
"from_snapshot" => {
from_snapshot = v.eq_ignore_ascii_case("true") || v == "1";
}
_ => {}
}
}
}
tracing::debug!("generated PO token (took {:?})", start.elapsed());
Ok((
tokens,
valid_until.unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::hours(12)),
))
let valid_until =
valid_until.unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::hours(12));
tracing::debug!(
"generated PO token (valid_until {}, from_snapshot={}, took {}ms)",
valid_until,
from_snapshot,
start.elapsed().as_millis()
);
Ok((tokens, valid_until))
}
/// Get a session-bound PO token (either from cache or newly generated)
async fn get_session_po_token(&self, visitor_data: &str) -> Result<PoToken, Error> {
if let Some(po_token) = self.client.inner.visitor_data_cache.get_pot(visitor_data) {
return Ok(po_token);
@ -2226,7 +2275,7 @@ impl RustyPipeQuery {
Ok(po_token)
}
/// Get a Proof-of-origin token
/// Get a PO token (Proof-of-origin token)
///
/// PO tokens are used by the web-based YouTube clients for requesting player data and video streams.
///
@ -2240,6 +2289,22 @@ impl RustyPipeQuery {
})
}
/// Get a new RustyPipeInfo object for reports
fn rp_info(&self) -> RustyPipeInfo<'_> {
RustyPipeInfo::new(
Some(self.opts.lang),
self.client
.inner
.botguard
.as_ref()
.map(|bg| bg.version.as_str()),
)
}
/// Execute a request to the YouTube API, then deobfuscate and map the response.
///
/// Runs a single attempt, returns Ok with a erroneous RequestResult in case of a
/// HTTP or mapping error so it can be retried/reported.
async fn execute_request_attempt<
R: DeserializeOwned + MapResponse<M> + Debug,
M,
@ -2331,6 +2396,10 @@ impl RustyPipeQuery {
})
}
/// Execute a request to the YouTube API, then deobfuscate and map the response.
///
/// Runs up to n_request_attempts, returns Ok with a erroneous RequestResult in case of a
/// HTTP or mapping error so it can be reported.
async fn execute_request_inner<
R: DeserializeOwned + MapResponse<M> + Debug,
M,
@ -2344,7 +2413,7 @@ impl RustyPipeQuery {
ctx_src: &MapRespOptions<'_>,
) -> Result<RequestResult<M>, Error> {
let mut last_resp = None;
for n in 0..=self.client.inner.n_request_attempts {
for n in 0..=self.client.inner.n_http_retries {
let resp = self
.execute_request_attempt::<R, M, B>(ctype, id, endpoint, body, ctx_src)
.await?;
@ -2362,7 +2431,7 @@ impl RustyPipeQuery {
// Remove the used visitor data from cache if the request resulted in a recoverable error
self.remove_visitor_data(&resp.visitor_data);
if n != self.client.inner.n_request_attempts {
if n != self.client.inner.n_http_retries {
let ms = util::retry_delay(n, 1000, 60000, 3);
tracing::warn!(
"Retry attempt #{}. Error: {}. Waiting {} ms",
@ -2437,7 +2506,7 @@ impl RustyPipeQuery {
if level > Level::DBG || self.opts.report {
if let Some(reporter) = &self.client.inner.reporter {
let report = Report {
info: RustyPipeInfo::new(Some(self.opts.lang)),
info: self.rp_info(),
level,
operation: &format!("{operation}({id})"),
error,
@ -2637,6 +2706,46 @@ fn local_tz_offset() -> (String, i16) {
}
}
/// Check if a valid Botguard binary is available at the given location
fn detect_botguard_bin(program: OsString) -> Result<(OsString, String), Error> {
let out = std::process::Command::new(&program)
.arg("--version")
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::Other("rustypipe-botguard binary not found".into())
} else {
Error::Other(format!("error calling rustypipe-botguard {e}").into())
}
})?;
if !out.status.success() {
return Err(Error::Extraction(ExtractionError::Botguard(
format!("version check failed with status {}", out.status).into(),
)));
}
let output = String::from_utf8_lossy(&out.stdout);
let pat = "rustypipe-botguard-api ";
let pos = output.find(pat).ok_or(Error::Other(
"no rustypipe-botguard-api version returned".into(),
))? + pat.len();
let pos_end = output[pos..]
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map(|(p, _)| p + pos)
.unwrap_or(output.len());
let api_version = &output[pos..pos_end];
if api_version != BOTGUARD_API_VERSION {
return Err(Error::Other(
format!(
"incompatible rustypipe-botguard-api version {api_version}, expected {BOTGUARD_API_VERSION}"
)
.into(),
));
}
let version = output[..pos].lines().next().unwrap_or_default().to_owned();
Ok((program, version))
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -122,20 +122,6 @@ impl RustyPipeQuery {
}
Ok(album)
}
/// Get all liked YouTube Music tracks of the logged-in user
///
/// The difference to [`RustyPipeQuery::music_saved_tracks`] is that this function only returns
/// tracks that were explicitly liked by the user.
///
/// Requires authentication cookies.
pub async fn music_liked_tracks(&self) -> Result<MusicPlaylist, Error> {
self.clone()
.authenticated()
.music_playlist("LM")
.await
.map_err(util::map_internal_playlist_err)
}
}
impl MapResponse<MusicPlaylist> for response::MusicPlaylist {

View file

@ -8,7 +8,7 @@ use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
AlbumItem, ArtistItem, HistoryItem, MusicPlaylistItem, TrackItem,
AlbumItem, ArtistItem, HistoryItem, MusicPlaylist, MusicPlaylistItem, TrackItem,
},
serializer::MapResult,
};
@ -127,6 +127,20 @@ impl RustyPipeQuery {
)
.await
}
/// Get all liked YouTube Music tracks of the logged-in user
///
/// The difference to [`RustyPipeQuery::music_saved_tracks`] is that this function only returns
/// tracks that were explicitly liked by the user.
///
/// Requires authentication cookies.
pub async fn music_liked_tracks(&self) -> Result<MusicPlaylist, Error> {
self.clone()
.authenticated()
.music_playlist("LM")
.await
.map_err(crate::util::map_internal_playlist_err)
}
}
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicHistory {
@ -195,7 +209,7 @@ mod tests {
#[test]
fn map_history() {
let json_path = path!(*TESTFILES / "music_history" / "music_history.json");
let json_path = path!(*TESTFILES / "music_userdata" / "music_history.json");
let json_file = File::open(json_path).unwrap();
let history: response::MusicHistory =

View file

@ -6,12 +6,15 @@ use crate::model::{
traits::FromYtItem,
Comment, MusicItem, YouTubeItem,
};
use crate::model::{HistoryItem, TrackItem, VideoItem};
use crate::serializer::MapResult;
use self::response::YouTubeListItem;
#[cfg(feature = "userdata")]
use crate::model::{HistoryItem, TrackItem, VideoItem};
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
use super::response::{
music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo},
YouTubeListItem,
};
use super::{
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
};
@ -225,6 +228,7 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
}
}
#[cfg(feature = "userdata")]
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
fn map_response(
self,
@ -270,6 +274,7 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
}
}
#[cfg(feature = "userdata")]
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicContinuation {
fn map_response(
self,
@ -422,6 +427,8 @@ impl Paginator<Comment> {
}
}
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
impl Paginator<HistoryItem<VideoItem>> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
@ -437,6 +444,8 @@ impl Paginator<HistoryItem<VideoItem>> {
}
}
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
impl Paginator<HistoryItem<TrackItem>> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
@ -533,7 +542,11 @@ macro_rules! paginator {
}
paginator!(Comment);
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
paginator!(HistoryItem<VideoItem>);
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
paginator!(HistoryItem<TrackItem>);
#[cfg(test)]
@ -620,7 +633,7 @@ mod tests {
}
#[rstest]
#[case::subscriptions("subscriptions", path!("history" / "subscriptions.json"))]
#[case::subscriptions("subscriptions", path!("userdata" / "subscriptions.json"))]
fn map_continuation_channels(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -644,7 +657,7 @@ mod tests {
#[case::playlist_tracks("playlist_tracks", path!("music_playlist" / "playlist_cont.json"))]
#[case::search_tracks("search_tracks", path!("music_search" / "tracks_cont.json"))]
#[case::radio_tracks("radio_tracks", path!("music_details" / "radio_cont.json"))]
#[case::saved_tracks("saved_tracks", path!("music_history" / "saved_tracks.json"))]
#[case::saved_tracks("saved_tracks", path!("music_userdata" / "saved_tracks.json"))]
fn map_continuation_tracks(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -665,7 +678,7 @@ mod tests {
}
#[rstest]
#[case::saved_artists("saved_artists", path!("music_history" / "saved_artists.json"))]
#[case::saved_artists("saved_artists", path!("music_userdata" / "saved_artists.json"))]
fn map_continuation_artists(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -686,7 +699,7 @@ mod tests {
}
#[rstest]
#[case::saved_albums("saved_albums", path!("music_history" / "saved_albums.json"))]
#[case::saved_albums("saved_albums", path!("music_userdata" / "saved_albums.json"))]
fn map_continuation_albums(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -708,7 +721,7 @@ mod tests {
#[rstest]
#[case::playlist_related("playlist_related", path!("music_playlist" / "playlist_related.json"))]
#[case::saved_playlists("saved_playlists", path!("music_history" / "saved_playlists.json"))]
#[case::saved_playlists("saved_playlists", path!("music_userdata" / "saved_playlists.json"))]
fn map_continuation_music_playlists(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();

View file

@ -33,28 +33,6 @@ impl RustyPipeQuery {
)
.await
}
/// Get all liked videos of the logged-in user
///
/// Requires authentication cookies.
pub async fn liked_videos(&self) -> Result<Playlist, Error> {
self.clone()
.authenticated()
.playlist("LL")
.await
.map_err(util::map_internal_playlist_err)
}
/// Get the "Watch later" playlist of the logged-in user
///
/// Requires authentication cookies.
pub async fn watch_later(&self) -> Result<Playlist, Error> {
self.clone()
.authenticated()
.playlist("WL")
.await
.map_err(util::map_internal_playlist_err)
}
}
impl MapResponse<Playlist> for response::Playlist {

View file

@ -1,10 +1,8 @@
pub(crate) mod channel;
pub(crate) mod history;
pub(crate) mod music_artist;
pub(crate) mod music_charts;
pub(crate) mod music_details;
pub(crate) mod music_genres;
pub(crate) mod music_history;
pub(crate) mod music_item;
pub(crate) mod music_new;
pub(crate) mod music_playlist;
@ -19,7 +17,6 @@ pub(crate) mod video_item;
pub(crate) use channel::Channel;
pub(crate) use channel::ChannelAbout;
pub(crate) use history::History;
pub(crate) use music_artist::MusicArtist;
pub(crate) use music_artist::MusicArtistAlbums;
pub(crate) use music_charts::MusicCharts;
@ -28,7 +25,6 @@ pub(crate) use music_details::MusicLyrics;
pub(crate) use music_details::MusicRelated;
pub(crate) use music_genres::MusicGenre;
pub(crate) use music_genres::MusicGenres;
pub(crate) use music_history::MusicHistory;
pub(crate) use music_item::MusicContinuation;
pub(crate) use music_new::MusicNew;
pub(crate) use music_playlist::MusicPlaylist;
@ -51,6 +47,15 @@ pub(crate) mod channel_rss;
#[cfg(feature = "rss")]
pub(crate) use channel_rss::ChannelRss;
#[cfg(feature = "userdata")]
pub(crate) mod history;
#[cfg(feature = "userdata")]
pub(crate) use history::History;
#[cfg(feature = "userdata")]
pub(crate) mod music_history;
#[cfg(feature = "userdata")]
pub(crate) use music_history::MusicHistory;
use std::borrow::Cow;
use std::collections::HashMap;
use std::marker::PhantomData;

View file

@ -1,11 +1,10 @@
use serde::Deserialize;
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
use time::UtcOffset;
use crate::{
model::{
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
HistoryItem, MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
},
param::Language,
serializer::{
@ -23,6 +22,11 @@ use super::{
SimpleHeaderRenderer, Thumbnails, ThumbnailsWrap,
};
#[cfg(feature = "userdata")]
use crate::model::HistoryItem;
#[cfg(feature = "userdata")]
use time::UtcOffset;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum ItemSection {
@ -40,6 +44,7 @@ pub(crate) enum ItemSection {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicShelf {
#[cfg(feature = "userdata")]
#[serde_as(as = "Option<Text>")]
pub title: Option<String>,
/// Playlist ID (only for playlists)
@ -1270,6 +1275,7 @@ impl MusicListMapper {
}
}
#[cfg(feature = "userdata")]
pub fn conv_history_items(
self,
date_txt: Option<String>,

View file

@ -2,14 +2,11 @@ use serde::Deserialize;
use serde_with::{
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
};
use time::{OffsetDateTime, UtcOffset};
use time::OffsetDateTime;
use super::{
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
Thumbnails,
};
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails};
use crate::{
model::{Channel, ChannelItem, ChannelTag, HistoryItem, PlaylistItem, VideoItem, YouTubeItem},
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
param::Language,
serializer::{
text::{AttributedText, Text, TextComponent},
@ -18,6 +15,11 @@ use crate::{
util::{self, timeago, TryRemove},
};
#[cfg(feature = "userdata")]
use crate::{client::response::SimpleHeaderRenderer, model::HistoryItem};
#[cfg(feature = "userdata")]
use time::UtcOffset;
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -66,6 +68,7 @@ pub(crate) enum YouTubeListItem {
/// GridRenderer: contains videos on channel page
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
ItemSectionRenderer {
#[cfg(feature = "userdata")]
header: Option<ItemSectionHeader>,
#[serde(alias = "items")]
contents: MapResult<Vec<YouTubeListItem>>,
@ -298,6 +301,7 @@ pub(crate) struct YouTubeListRenderer {
pub contents: MapResult<Vec<YouTubeListItem>>,
}
#[cfg(feature = "userdata")]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ItemSectionHeader {
@ -904,6 +908,7 @@ impl YouTubeListMapper<VideoItem> {
res.c.into_iter().for_each(|item| self.map_item(item));
}
#[cfg(feature = "userdata")]
pub(crate) fn conv_history_items(
self,
date_txt: Option<String>,

View file

@ -7,7 +7,7 @@ use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
ChannelItem, HistoryItem, PlaylistItem, VideoItem,
ChannelItem, HistoryItem, Playlist, PlaylistItem, VideoItem,
},
serializer::MapResult,
};
@ -148,6 +148,28 @@ impl RustyPipeQuery {
)
.await
}
/// Get all liked videos of the logged-in user
///
/// Requires authentication cookies.
pub async fn liked_videos(&self) -> Result<Playlist, Error> {
self.clone()
.authenticated()
.playlist("LL")
.await
.map_err(crate::util::map_internal_playlist_err)
}
/// Get the "Watch later" playlist of the logged-in user
///
/// Requires authentication cookies.
pub async fn watch_later(&self) -> Result<Playlist, Error> {
self.clone()
.authenticated()
.playlist("WL")
.await
.map_err(crate::util::map_internal_playlist_err)
}
}
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::History {
@ -258,7 +280,7 @@ mod tests {
#[test]
fn map_history() {
let json_path = path!(*TESTFILES / "history" / "history.json");
let json_path = path!(*TESTFILES / "userdata" / "history.json");
let json_file = File::open(json_path).unwrap();
let history: response::History =
@ -278,7 +300,7 @@ mod tests {
#[test]
fn map_subscription_feed() {
let json_path = path!(*TESTFILES / "history" / "subscription_feed.json");
let json_path = path!(*TESTFILES / "userdata" / "subscription_feed.json");
let json_file = File::open(json_path).unwrap();
let history: response::History =

View file

@ -39,7 +39,7 @@ impl DeobfData {
if let Err(e) = &res {
if let Some(reporter) = reporter {
let report = Report {
info: RustyPipeInfo::new(None),
info: RustyPipeInfo::new(None, None),
level: Level::ERR,
operation: "extract_deobf",
error: Some(e.to_string()),

View file

@ -70,6 +70,8 @@ pub struct RustyPipeInfo<'a> {
/// YouTube content language
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<Language>,
/// RustyPipe Botguard version (`rustypipe-botguard 0.1.1`)
pub botguard_version: Option<&'a str>,
}
/// Reported HTTP request data
@ -104,13 +106,14 @@ pub enum Level {
ERR,
}
impl RustyPipeInfo<'_> {
pub(crate) fn new(language: Option<Language>) -> Self {
impl<'a> RustyPipeInfo<'a> {
pub(crate) fn new(language: Option<Language>, botguard_version: Option<&'a str>) -> Self {
Self {
package: env!("CARGO_PKG_NAME"),
version: crate::VERSION,
date: util::now_sec(),
language,
botguard_version,
}
}
}

View file

@ -21,7 +21,7 @@ use regex::Regex;
use url::Url;
use crate::{
error::{AuthError, Error, ExtractionError},
error::Error,
param::{Country, Language, COUNTRIES},
serializer::text::TextComponent,
};
@ -75,10 +75,10 @@ pub fn get_cg_from_fancy_regexes(regexes: &[&str], text: &str, cg_name: &str) ->
/// Generate a random string with given length and byte charset.
fn random_string(charset: &[u8], length: usize) -> String {
let mut result = String::with_capacity(length);
let mut rng = rand::rng();
let mut rng = rand::thread_rng();
for _ in 0..length {
result.push(char::from(charset[rng.random_range(0..charset.len())]));
result.push(char::from(charset[rng.gen_range(0..charset.len())]));
}
result
@ -90,14 +90,14 @@ pub fn generate_content_playback_nonce() -> String {
}
pub fn random_uuid() -> String {
let mut rng = rand::rng();
let mut rng = rand::thread_rng();
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
rng.random::<u32>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u64>() & 0xffff_ffff_ffff,
rng.gen::<u32>(),
rng.gen::<u16>(),
rng.gen::<u16>(),
rng.gen::<u16>(),
rng.gen::<u64>() & 0xffff_ffff_ffff,
)
}
@ -229,7 +229,7 @@ pub fn retry_delay(
backoff_base: u32,
) -> u32 {
let unjittered_delay = backoff_base.checked_pow(n_past_retries).unwrap_or(u32::MAX);
let jitter_factor = rand::rng().random_range(800..1500);
let jitter_factor = rand::thread_rng().gen_range(800..1500);
let jittered_delay = unjittered_delay
.checked_mul(jitter_factor)
.unwrap_or(u32::MAX);
@ -581,9 +581,10 @@ where
///
/// If no user is logged in, YouTube returns a "NotFound" error. This has to be corrected
/// into a NoLogin error.
#[cfg(feature = "userdata")]
pub fn map_internal_playlist_err(e: Error) -> Error {
if let Error::Extraction(ExtractionError::NotFound { .. }) = e {
Error::Auth(AuthError::NoLogin)
if let Error::Extraction(crate::error::ExtractionError::NotFound { .. }) = e {
Error::Auth(crate::error::AuthError::NoLogin)
} else {
e
}

View file

@ -347,6 +347,7 @@ pub fn parse_textual_date_to_dt(
/// Parse a textual date (e.g. "29 minutes ago" "Jul 2, 2014") into a Date object.
///
/// Returns None if the date could not be parsed.
#[cfg(feature = "userdata")]
pub fn parse_textual_date_to_d(
lang: Language,
utc_offset: UtcOffset,

View file

@ -148,8 +148,8 @@ impl VisitorDataCache {
{
let vds = self.inner.visitor_data.read().unwrap();
if !vds.is_empty() {
let mut rng = rand::rng();
let vd = vds[rng.random_range(0..vds.len())].to_owned();
let mut rng = rand::thread_rng();
let vd = vds[rng.gen_range(0..vds.len())].to_owned();
tracing::debug!("visitor data {vd} picked from cache");
return Ok(vd);
}
@ -181,7 +181,7 @@ impl VisitorDataCache {
pub fn get_pot(&self, visitor_data: &str) -> Option<PoToken> {
let pots = self.inner.session_potoken.read().unwrap();
if let Some(entry) = pots.get(visitor_data) {
if entry.valid_until > OffsetDateTime::now_utc() {
if entry.valid_until > OffsetDateTime::now_utc() + time::Duration::minutes(10) {
return Some(entry.clone());
}
}

View file

@ -146,21 +146,6 @@ MusicArtist(
year: Some(2015),
by_va: false,
),
AlbumItem(
id: "MPREb_ghrNI6BJSM8",
name: "Friends And Family",
cover: "[cover]",
artists: [
ArtistId(
id: Some("UCFKUUtHjT4iq3p0JJA13SOA"),
name: "Every Time I Die",
),
],
artist_id: Some("UCFKUUtHjT4iq3p0JJA13SOA"),
album_type: album,
year: Some(2017),
by_va: false,
),
AlbumItem(
id: "MPREb_h0UZr2ALQXf",
name: "From Parts Unknown (Deluxe Edition)",

View file

@ -5,7 +5,7 @@ use std::fmt::Display;
use std::str::FromStr;
use rstest::{fixture, rstest};
use rustypipe::model::{HistoryItem, TrackItem, TrackType, VideoItem};
use rustypipe::model::TrackType;
use rustypipe::param::{AlbumOrder, LANGUAGES};
use time::{macros::date, OffsetDateTime};
@ -2728,9 +2728,12 @@ async fn isrc_search_languages(rp: RustyPipe) {
}
}
mod cookie_auth {
#[cfg(feature = "userdata")]
mod user_data {
use super::*;
use rustypipe::model::{HistoryItem, TrackItem, VideoItem};
#[rstest]
#[tokio::test]
async fn history(rp: RustyPipe) {
@ -2814,6 +2817,30 @@ mod cookie_auth {
let tracks = rp.query().music_liked_tracks().await.unwrap();
assert_next_items(tracks.tracks, rp.query(), 5).await;
}
/// Assert that the history paginator produces at least n items
async fn assert_next_history<Q: AsRef<RustyPipeQuery>>(
paginator: Paginator<HistoryItem<VideoItem>>,
query: Q,
n_items: usize,
) {
let mut p = paginator;
let query = query.as_ref();
p.extend_limit(query, n_items).await.unwrap();
assert_gte(p.items.len(), n_items, "items");
}
/// Assert that the music history paginator produces at least n items
async fn assert_next_music_history<Q: AsRef<RustyPipeQuery>>(
paginator: Paginator<HistoryItem<TrackItem>>,
query: Q,
n_items: usize,
) {
let mut p = paginator;
let query = query.as_ref();
p.extend_limit(query, n_items).await.unwrap();
assert_gte(p.items.len(), n_items, "items");
}
}
#[rstest]
@ -2940,30 +2967,6 @@ async fn assert_next_items<T: FromYtItem, Q: AsRef<RustyPipeQuery>>(
assert_gte(p.items.len(), n_items, "items");
}
/// Assert that the history paginator produces at least n items
async fn assert_next_history<Q: AsRef<RustyPipeQuery>>(
paginator: Paginator<HistoryItem<VideoItem>>,
query: Q,
n_items: usize,
) {
let mut p = paginator;
let query = query.as_ref();
p.extend_limit(query, n_items).await.unwrap();
assert_gte(p.items.len(), n_items, "items");
}
/// Assert that the music history paginator produces at least n items
async fn assert_next_music_history<Q: AsRef<RustyPipeQuery>>(
paginator: Paginator<HistoryItem<TrackItem>>,
query: Q,
n_items: usize,
) {
let mut p = paginator;
let query = query.as_ref();
p.extend_limit(query, n_items).await.unwrap();
assert_gte(p.items.len(), n_items, "items");
}
#[track_caller]
fn assert_frameset(frameset: &Frameset) {
assert_gte(frameset.frame_height, 20, "frame height");
@ -3025,10 +3028,6 @@ async fn all_send_and_sync() {
rp.query()
.drm_license("", rustypipe::model::DrmSystem::Widevine, "", "", &[]),
);
send_and_sync(rp.query().history());
send_and_sync(rp.query().history_continuation("", None));
send_and_sync(rp.query().history_search(""));
send_and_sync(rp.query().liked_videos());
send_and_sync(rp.query().music_album(""));
send_and_sync(rp.query().music_artist("", false));
send_and_sync(rp.query().music_artist_albums("", None, None));
@ -3037,9 +3036,6 @@ async fn all_send_and_sync() {
send_and_sync(rp.query().music_details(""));
send_and_sync(rp.query().music_genre(""));
send_and_sync(rp.query().music_genres());
send_and_sync(rp.query().music_history());
send_and_sync(rp.query().music_history_continuation("", None));
send_and_sync(rp.query().music_liked_tracks());
send_and_sync(rp.query().music_lyrics(""));
send_and_sync(rp.query().music_new_albums());
send_and_sync(rp.query().music_new_videos());
@ -3048,10 +3044,6 @@ async fn all_send_and_sync() {
send_and_sync(rp.query().music_radio_playlist(""));
send_and_sync(rp.query().music_radio_track(""));
send_and_sync(rp.query().music_related(""));
send_and_sync(rp.query().music_saved_albums());
send_and_sync(rp.query().music_saved_artists());
send_and_sync(rp.query().music_saved_playlists());
send_and_sync(rp.query().music_saved_tracks());
send_and_sync(rp.query().music_search::<MusicItem, _>("", None));
send_and_sync(rp.query().music_search_albums(""));
send_and_sync(rp.query().music_search_artists(""));
@ -3068,17 +3060,32 @@ async fn all_send_and_sync() {
send_and_sync(rp.query().raw(ClientType::Desktop, "", ""));
send_and_sync(rp.query().resolve_string("", false));
send_and_sync(rp.query().resolve_url("", false));
send_and_sync(rp.query().saved_playlists());
send_and_sync(rp.query().search::<YouTubeItem, _>(""));
send_and_sync(
rp.query()
.search_filter::<YouTubeItem, _>("", &SearchFilter::default()),
);
send_and_sync(rp.query().search_suggestion(""));
send_and_sync(rp.query().subscription_feed());
send_and_sync(rp.query().subscriptions());
send_and_sync(rp.query().trending());
send_and_sync(rp.query().video_comments("", None));
send_and_sync(rp.query().video_details(""));
#[cfg(feature = "userdata")]
{
send_and_sync(rp.query().history());
send_and_sync(rp.query().history_continuation("", None));
send_and_sync(rp.query().history_search(""));
send_and_sync(rp.query().liked_videos());
send_and_sync(rp.query().watch_later());
send_and_sync(rp.query().music_history());
send_and_sync(rp.query().music_history_continuation("", None));
send_and_sync(rp.query().music_saved_albums());
send_and_sync(rp.query().music_saved_artists());
send_and_sync(rp.query().music_saved_playlists());
send_and_sync(rp.query().music_saved_tracks());
send_and_sync(rp.query().saved_playlists());
send_and_sync(rp.query().subscription_feed());
send_and_sync(rp.query().subscriptions());
send_and_sync(rp.query().music_liked_tracks());
}
}