Compare commits
17 commits
renovate/r
...
main
Author | SHA1 | Date | |
---|---|---|---|
83f8652776 | |||
739eac4d1f | |||
4d60e64f2c | |||
45d3a9cd33 | |||
f8a0a253cc | |||
8933c6fa2a | |||
629b5905da | |||
26e0c2cb2b | |||
fb1b732d56 | |||
80a358ee54 | |||
c0770f281c | |||
1d755b76bf | |||
9957add2b5 | |||
c1a872e1c1 | |||
0c94267d03 | |||
a80f046a19 | |||
65cb4244c6 |
46 changed files with 608 additions and 258 deletions
|
@ -28,17 +28,22 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
TARGET=$(rustc --version --verbose | grep "host:" | sed -e 's/^host: //')
|
TARGET=$(rustc --version --verbose | grep "host:" | sed -e 's/^host: //')
|
||||||
cd ~
|
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
|
cd /usr/local/bin
|
||||||
sudo tar -xJf ~/rustypipe-botguard.tar.xz
|
sudo tar -xJf ~/rustypipe-botguard.tar.xz
|
||||||
rm ~/rustypipe-botguard.tar.xz
|
rm ~/rustypipe-botguard.tar.xz
|
||||||
rustypipe-botguard --version
|
rustypipe-botguard --version
|
||||||
|
|
||||||
- name: 📎 Clippy
|
- 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
|
- 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:
|
env:
|
||||||
ALL_PROXY: "http://warpproxy:8124"
|
ALL_PROXY: "http://warpproxy:8124"
|
||||||
|
|
||||||
|
|
69
.forgejo/workflows/release-cli.yaml
Normal file
69
.forgejo/workflows/release-cli.yaml
Normal 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/*
|
|
@ -10,4 +10,8 @@ repos:
|
||||||
hooks:
|
hooks:
|
||||||
- id: cargo-fmt
|
- id: cargo-fmt
|
||||||
- id: cargo-clippy
|
- 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"]
|
||||||
|
|
|
@ -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
|
|
38
CHANGELOG.md
38
CHANGELOG.md
|
@ -3,6 +3,44 @@
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v0.9.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.8.0..rustypipe/v0.9.0) - 2025-01-16
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
13
Cargo.toml
13
Cargo.toml
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "rustypipe"
|
name = "rustypipe"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
rust-version = "1.67.1"
|
rust-version = "1.67.1"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
@ -40,7 +40,7 @@ serde_with = { version = "3.0.0", default-features = false, features = [
|
||||||
] }
|
] }
|
||||||
serde_plain = "1.0.0"
|
serde_plain = "1.0.0"
|
||||||
sha1 = "0.10.0"
|
sha1 = "0.10.0"
|
||||||
rand = "0.9.0"
|
rand = "0.8.0"
|
||||||
time = { version = "0.3.37", features = [
|
time = { version = "0.3.37", features = [
|
||||||
"macros",
|
"macros",
|
||||||
"serde-human-readable",
|
"serde-human-readable",
|
||||||
|
@ -74,8 +74,8 @@ path_macro = "1.0.0"
|
||||||
tracing-test = "0.2.5"
|
tracing-test = "0.2.5"
|
||||||
|
|
||||||
# Included crates
|
# Included crates
|
||||||
rustypipe = { path = ".", version = "0.9.0", default-features = false }
|
rustypipe = { path = ".", version = "0.10.0", default-features = false }
|
||||||
rustypipe-downloader = { path = "./downloader", version = "0.2.1", default-features = false, features = [
|
rustypipe-downloader = { path = "./downloader", version = "0.3.0", default-features = false, features = [
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"audiotag",
|
"audiotag",
|
||||||
] }
|
] }
|
||||||
|
@ -84,6 +84,7 @@ rustypipe-downloader = { path = "./downloader", version = "0.2.1", default-featu
|
||||||
default = ["default-tls"]
|
default = ["default-tls"]
|
||||||
|
|
||||||
rss = ["dep:quick-xml"]
|
rss = ["dep:quick-xml"]
|
||||||
|
userdata = []
|
||||||
|
|
||||||
# Reqwest TLS options
|
# Reqwest TLS options
|
||||||
default-tls = ["reqwest/default-tls"]
|
default-tls = ["reqwest/default-tls"]
|
||||||
|
@ -126,6 +127,6 @@ tracing-test.workspace = true
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
[package.metadata.docs.rs]
|
||||||
# To build locally:
|
# To build locally:
|
||||||
# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features rss --no-deps --open
|
# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features rss,userdata --no-deps --open
|
||||||
features = ["rss"]
|
features = ["rss", "userdata"]
|
||||||
rustdoc-args = ["--cfg", "docsrs"]
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
|
|
16
Justfile
16
Justfile
|
@ -1,19 +1,19 @@
|
||||||
test:
|
test:
|
||||||
# cargo test --features=rss
|
# cargo test --features=rss,userdata
|
||||||
cargo nextest run --workspace --features=rss --no-fail-fast --retries 1 -- --skip 'cookie_auth::'
|
cargo nextest run --workspace --features=rss,userdata --no-fail-fast --retries 1 -- --skip 'user_data::'
|
||||||
|
|
||||||
unittest:
|
unittest:
|
||||||
cargo nextest run --features=rss --no-fail-fast --lib
|
cargo nextest run --features=rss,userdata --no-fail-fast --lib
|
||||||
|
|
||||||
testyt:
|
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:
|
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:
|
testyt-localized:
|
||||||
YT_LANG=th cargo nextest run --features=rss --no-fail-fast --retries 1 --test youtube -- \
|
YT_LANG=th cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube -- \
|
||||||
--skip 'cookie_auth::' --skip 'search_suggestion' --skip 'isrc_search_languages'
|
--skip 'user_data::' --skip 'search_suggestion' --skip 'isrc_search_languages'
|
||||||
|
|
||||||
testintl:
|
testintl:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
@ -33,7 +33,7 @@ testintl:
|
||||||
echo "---TESTS FOR $YT_LANG ---"
|
echo "---TESTS FOR $YT_LANG ---"
|
||||||
|
|
||||||
if YT_LANG="$YT_LANG" cargo nextest run --no-fail-fast --retries 1 --test-threads 4 --test youtube -- \
|
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 ---"
|
echo "--- $YT_LANG COMPLETED ---"
|
||||||
else
|
else
|
||||||
echo "--- $YT_LANG FAILED ---"
|
echo "--- $YT_LANG FAILED ---"
|
||||||
|
|
28
README.md
28
README.md
|
@ -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
|
## Cache storage
|
||||||
|
|
||||||
The RustyPipe cache holds the current version numbers for all clients, the JavaScript
|
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)
|
incomplete)
|
||||||
- ERR (entire response could not be deserialized/parsed, RustyPipe returned an error)
|
- 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
|
## Authentication
|
||||||
|
|
||||||
RustyPipe supports authenticating with your YouTube account to access
|
RustyPipe supports authenticating with your YouTube account to access
|
||||||
|
|
|
@ -3,6 +3,36 @@
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v0.6.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.5.0..rustypipe-cli/v0.6.0) - 2025-01-16
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "rustypipe-cli"
|
name = "rustypipe-cli"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
rust-version = "1.70.0"
|
rust-version = "1.70.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
@ -42,7 +42,7 @@ rustls-tls-native-roots = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rustypipe = { workspace = true, features = ["rss"] }
|
rustypipe = { workspace = true, features = ["rss", "userdata"] }
|
||||||
rustypipe-downloader.workspace = true
|
rustypipe-downloader.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
@ -64,3 +64,7 @@ dirs.workspace = true
|
||||||
anstream = "0.6.15"
|
anstream = "0.6.15"
|
||||||
owo-colors = "4.0.0"
|
owo-colors = "4.0.0"
|
||||||
const_format = "0.2.33"
|
const_format = "0.2.33"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "rustypipe"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
|
@ -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
|
access most of the features of the RustyPipe crate: getting data from YouTube and
|
||||||
downloading videos.
|
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
|
## `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`
|
- **Proxy:** RustyPipe respects the environment variables `HTTP_PROXY`, `HTTPS_PROXY`
|
||||||
and `ALL_PROXY`
|
and `ALL_PROXY`
|
||||||
- **Logging:** You can change the log level with the `RUST_LOG` environment variable, it
|
- **Logging:** Enable debug logging with the `-v` (verbose) flag. If you want more
|
||||||
is set to `info` by default
|
fine-grained control, use the `RUST_LOG` environment variable.
|
||||||
- **Visitor data:** A custom visitor data ID can be used with the `--vdata` flag
|
- **Visitor data:** A custom visitor data ID can be used with the `--vdata` flag
|
||||||
- **Authentication:** Use the commands `rustypipe login` and `rustypipe login --cookie`
|
- **Authentication:** Use the commands `rustypipe login` and `rustypipe login --cookie`
|
||||||
to log into your Google account using either OAuth or YouTube cookies. With the
|
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)
|
Europe/Berlin, Australia/Sydney)
|
||||||
|
|
||||||
**Note:** this requires building rustypipe-cli with the `timezone` feature
|
**Note:** this requires building rustypipe-cli with the `timezone` feature
|
||||||
|
|
||||||
- `--local-tz` Use the local timezone instead of UTC
|
- `--local-tz` Use the local timezone instead of UTC
|
||||||
- `--report` Generate a report on every request and store it in a `rustypipe_reports`
|
- `--report` Generate a report on every request and store it in a `rustypipe_reports`
|
||||||
folder in the current directory
|
folder in the current directory
|
||||||
|
|
|
@ -80,6 +80,9 @@ struct Cli {
|
||||||
/// Enable caching for session-bound PO tokens
|
/// Enable caching for session-bound PO tokens
|
||||||
#[clap(long, global = true)]
|
#[clap(long, global = true)]
|
||||||
pot_cache: bool,
|
pot_cache: bool,
|
||||||
|
/// Enable debug logging
|
||||||
|
#[clap(short, long, global = true)]
|
||||||
|
verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
@ -878,12 +881,15 @@ async fn run() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let multi = MultiProgress::new();
|
let multi = MultiProgress::new();
|
||||||
|
|
||||||
|
let mut env_filter = EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
|
.from_env_lossy();
|
||||||
|
if cli.verbose {
|
||||||
|
env_filter = env_filter.add_directive("rustypipe=debug".parse().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
tracing_subscriber::fmt::SubscriberBuilder::default()
|
tracing_subscriber::fmt::SubscriberBuilder::default()
|
||||||
.with_env_filter(
|
.with_env_filter(env_filter)
|
||||||
EnvFilter::builder()
|
|
||||||
.with_default_directive(LevelFilter::INFO.into())
|
|
||||||
.from_env_lossy(),
|
|
||||||
)
|
|
||||||
.with_writer(ProgWriter(multi.clone()))
|
.with_writer(ProgWriter(multi.clone()))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ repository.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rustypipe = { path = "../" }
|
rustypipe = { path = "../", features = ["userdata"] }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
|
|
|
@ -39,9 +39,6 @@ pub async fn download_testfiles() {
|
||||||
search_playlists().await;
|
search_playlists().await;
|
||||||
search_empty().await;
|
search_empty().await;
|
||||||
trending().await;
|
trending().await;
|
||||||
history().await;
|
|
||||||
subscriptions().await;
|
|
||||||
subscription_feed().await;
|
|
||||||
|
|
||||||
music_playlist().await;
|
music_playlist().await;
|
||||||
music_playlist_cont().await;
|
music_playlist_cont().await;
|
||||||
|
@ -65,6 +62,12 @@ pub async fn download_testfiles() {
|
||||||
music_charts().await;
|
music_charts().await;
|
||||||
music_genres().await;
|
music_genres().await;
|
||||||
music_genre().await;
|
music_genre().await;
|
||||||
|
|
||||||
|
// User data
|
||||||
|
history().await;
|
||||||
|
subscriptions().await;
|
||||||
|
subscription_feed().await;
|
||||||
|
|
||||||
music_history().await;
|
music_history().await;
|
||||||
music_saved_artists().await;
|
music_saved_artists().await;
|
||||||
music_saved_albums().await;
|
music_saved_albums().await;
|
||||||
|
@ -464,7 +467,7 @@ async fn trending() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn history() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -474,7 +477,7 @@ async fn history() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn subscriptions() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -484,7 +487,7 @@ async fn subscriptions() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn subscription_feed() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -816,7 +819,7 @@ async fn music_genre() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn music_history() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -826,7 +829,7 @@ async fn music_history() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn music_saved_artists() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -836,7 +839,7 @@ async fn music_saved_artists() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn music_saved_albums() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -846,7 +849,7 @@ async fn music_saved_albums() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn music_saved_tracks() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -856,7 +859,7 @@ async fn music_saved_tracks() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn music_saved_playlists() {
|
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() {
|
if json_path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,30 @@
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v0.2.7](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.6..rustypipe-downloader/v0.2.7) - 2025-01-16
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "rustypipe-downloader"
|
name = "rustypipe-downloader"
|
||||||
version = "0.2.7"
|
version = "0.3.0"
|
||||||
rust-version = "1.67.1"
|
rust-version = "1.67.1"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
|
@ -1063,8 +1063,8 @@ impl DownloadQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_download_range(offset: u64, size: Option<u64>) -> Range<u64> {
|
fn get_download_range(offset: u64, size: Option<u64>) -> Range<u64> {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::thread_rng();
|
||||||
let chunk_size = rng.random_range(CHUNK_SIZE_MIN..CHUNK_SIZE_MAX);
|
let chunk_size = rng.gen_range(CHUNK_SIZE_MIN..CHUNK_SIZE_MAX);
|
||||||
let mut chunk_end = offset + chunk_size;
|
let mut chunk_end = offset + chunk_size;
|
||||||
|
|
||||||
if let Some(size) = size {
|
if let Some(size) = size {
|
||||||
|
|
|
@ -3,7 +3,7 @@ use std::fmt::Debug;
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
model::ChannelRss,
|
model::ChannelRss,
|
||||||
report::{Report, RustyPipeInfo},
|
report::Report,
|
||||||
util,
|
util,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ impl RustyPipeQuery {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Some(reporter) = &self.client.inner.reporter {
|
if let Some(reporter) = &self.client.inner.reporter {
|
||||||
let report = Report {
|
let report = Report {
|
||||||
info: RustyPipeInfo::new(Some(self.opts.lang)),
|
info: self.rp_info(),
|
||||||
level: crate::report::Level::ERR,
|
level: crate::report::Level::ERR,
|
||||||
operation: "channel_rss",
|
operation: "channel_rss",
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
|
|
@ -3,12 +3,10 @@
|
||||||
pub(crate) mod response;
|
pub(crate) mod response;
|
||||||
|
|
||||||
mod channel;
|
mod channel;
|
||||||
mod history;
|
|
||||||
mod music_artist;
|
mod music_artist;
|
||||||
mod music_charts;
|
mod music_charts;
|
||||||
mod music_details;
|
mod music_details;
|
||||||
mod music_genres;
|
mod music_genres;
|
||||||
mod music_history;
|
|
||||||
mod music_new;
|
mod music_new;
|
||||||
mod music_playlist;
|
mod music_playlist;
|
||||||
mod music_search;
|
mod music_search;
|
||||||
|
@ -20,6 +18,13 @@ mod trends;
|
||||||
mod url_resolver;
|
mod url_resolver;
|
||||||
mod video_details;
|
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(feature = "rss")]
|
||||||
#[cfg_attr(docsrs, doc(cfg(feature = "rss")))]
|
#[cfg_attr(docsrs, doc(cfg(feature = "rss")))]
|
||||||
mod channel_rss;
|
mod channel_rss;
|
||||||
|
@ -357,6 +362,8 @@ const OAUTH_CLIENT_ID: &str =
|
||||||
const OAUTH_CLIENT_SECRET: &str = "SboVhoG9s0rNafixCSGGKXAT";
|
const OAUTH_CLIENT_SECRET: &str = "SboVhoG9s0rNafixCSGGKXAT";
|
||||||
const OAUTH_SCOPES: &str = "http://gdata.youtube.com https://www.googleapis.com/auth/youtube";
|
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> =
|
static CLIENT_VERSION_REGEX: Lazy<Regex> =
|
||||||
Lazy::new(|| Regex::new(r#""INNERTUBE_CONTEXT_CLIENT_VERSION":"([\w\d\._-]+?)""#).unwrap());
|
Lazy::new(|| Regex::new(r#""INNERTUBE_CONTEXT_CLIENT_VERSION":"([\w\d\._-]+?)""#).unwrap());
|
||||||
|
|
||||||
|
@ -374,7 +381,7 @@ struct RustyPipeRef {
|
||||||
http: Client,
|
http: Client,
|
||||||
storage: Option<Box<dyn CacheStorage>>,
|
storage: Option<Box<dyn CacheStorage>>,
|
||||||
reporter: Option<Box<dyn Reporter>>,
|
reporter: Option<Box<dyn Reporter>>,
|
||||||
n_request_attempts: u32,
|
n_http_retries: u32,
|
||||||
cache: CacheHolder,
|
cache: CacheHolder,
|
||||||
default_opts: RustyPipeOpts,
|
default_opts: RustyPipeOpts,
|
||||||
user_agent: Cow<'static, str>,
|
user_agent: Cow<'static, str>,
|
||||||
|
@ -398,17 +405,19 @@ struct RustyPipeOpts {
|
||||||
pub struct RustyPipeBuilder {
|
pub struct RustyPipeBuilder {
|
||||||
storage: DefaultOpt<Box<dyn CacheStorage>>,
|
storage: DefaultOpt<Box<dyn CacheStorage>>,
|
||||||
reporter: DefaultOpt<Box<dyn Reporter>>,
|
reporter: DefaultOpt<Box<dyn Reporter>>,
|
||||||
n_request_attempts: u32,
|
n_http_retries: u32,
|
||||||
timeout: DefaultOpt<Duration>,
|
timeout: DefaultOpt<Duration>,
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
default_opts: RustyPipeOpts,
|
default_opts: RustyPipeOpts,
|
||||||
storage_dir: Option<PathBuf>,
|
storage_dir: Option<PathBuf>,
|
||||||
botguard_bin: DefaultOpt<OsString>,
|
botguard_bin: DefaultOpt<OsString>,
|
||||||
|
snapshot_file: Option<PathBuf>,
|
||||||
po_token_cache: bool,
|
po_token_cache: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BotguardCfg {
|
struct BotguardCfg {
|
||||||
program: OsString,
|
program: OsString,
|
||||||
|
version: String,
|
||||||
snapshot_file: PathBuf,
|
snapshot_file: PathBuf,
|
||||||
po_token_cache: bool,
|
po_token_cache: bool,
|
||||||
}
|
}
|
||||||
|
@ -436,13 +445,6 @@ impl<T> DefaultOpt<T> {
|
||||||
DefaultOpt::Default => Some(f()),
|
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
|
/// # RustyPipe query
|
||||||
|
@ -503,6 +505,26 @@ impl<T> DefaultOpt<T> {
|
||||||
/// - [`music_new_albums`](RustyPipeQuery::music_new_albums)
|
/// - [`music_new_albums`](RustyPipeQuery::music_new_albums)
|
||||||
/// - [`music_new_videos`](RustyPipeQuery::music_new_videos)
|
/// - [`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
|
/// ## Options
|
||||||
///
|
///
|
||||||
/// You can set the language, country and visitor data ID for individual requests.
|
/// You can set the language, country and visitor data ID for individual requests.
|
||||||
|
@ -655,10 +677,11 @@ impl RustyPipeBuilder {
|
||||||
storage: DefaultOpt::Default,
|
storage: DefaultOpt::Default,
|
||||||
reporter: DefaultOpt::Default,
|
reporter: DefaultOpt::Default,
|
||||||
timeout: DefaultOpt::Default,
|
timeout: DefaultOpt::Default,
|
||||||
n_request_attempts: 2,
|
n_http_retries: 2,
|
||||||
user_agent: None,
|
user_agent: None,
|
||||||
storage_dir: None,
|
storage_dir: None,
|
||||||
botguard_bin: DefaultOpt::Default,
|
botguard_bin: DefaultOpt::Default,
|
||||||
|
snapshot_file: None,
|
||||||
po_token_cache: false,
|
po_token_cache: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -724,27 +747,31 @@ impl RustyPipeBuilder {
|
||||||
|
|
||||||
let visitor_data_cache = VisitorDataCache::new(http.clone(), 50, 20);
|
let visitor_data_cache = VisitorDataCache::new(http.clone(), 50, 20);
|
||||||
|
|
||||||
let botguard_bin = self.botguard_bin.or_default_opt(|| {
|
let botguard = match self.botguard_bin {
|
||||||
let n = OsString::from("rustypipe-botguard");
|
DefaultOpt::Some(botguard_bin) => Some(detect_botguard_bin(botguard_bin)?),
|
||||||
let out = std::process::Command::new(&n)
|
DefaultOpt::None => None,
|
||||||
.arg("--version")
|
DefaultOpt::Default => detect_botguard_bin("./rustypipe-botguard".into())
|
||||||
.output()
|
.or_else(|_| detect_botguard_bin("rustypipe-botguard".into()))
|
||||||
.ok()?;
|
.map_err(|e| tracing::debug!("could not detect rustypipe-botguard: {e}"))
|
||||||
if !out.status.success() {
|
.ok(),
|
||||||
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,
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
}
|
|
||||||
Some(n)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(RustyPipe {
|
Ok(RustyPipe {
|
||||||
|
@ -752,11 +779,11 @@ impl RustyPipeBuilder {
|
||||||
http,
|
http,
|
||||||
storage,
|
storage,
|
||||||
reporter: self.reporter.or_default(|| {
|
reporter: self.reporter.or_default(|| {
|
||||||
let mut report_dir = storage_dir.clone();
|
let mut report_dir = storage_dir;
|
||||||
report_dir.push(DEFAULT_REPORT_DIR);
|
report_dir.push(DEFAULT_REPORT_DIR);
|
||||||
Box::new(FileReporter::new(report_dir))
|
Box::new(FileReporter::new(report_dir))
|
||||||
}),
|
}),
|
||||||
n_request_attempts: self.n_request_attempts,
|
n_http_retries: self.n_http_retries,
|
||||||
cache: CacheHolder {
|
cache: CacheHolder {
|
||||||
clients: cache_clients,
|
clients: cache_clients,
|
||||||
deobf: AsyncRwLock::new(cdata.deobf),
|
deobf: AsyncRwLock::new(cdata.deobf),
|
||||||
|
@ -766,15 +793,7 @@ impl RustyPipeBuilder {
|
||||||
default_opts: self.default_opts,
|
default_opts: self.default_opts,
|
||||||
user_agent,
|
user_agent,
|
||||||
visitor_data_cache,
|
visitor_data_cache,
|
||||||
botguard: botguard_bin.map(|program| {
|
botguard,
|
||||||
let mut snapshot_file = storage_dir;
|
|
||||||
snapshot_file.push("bg_snapshot.bin");
|
|
||||||
BotguardCfg {
|
|
||||||
program,
|
|
||||||
snapshot_file,
|
|
||||||
po_token_cache: self.po_token_cache,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -843,9 +862,9 @@ impl RustyPipeBuilder {
|
||||||
self
|
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.
|
/// RustyPipe waits 1 second before the next attempt.
|
||||||
///
|
///
|
||||||
/// The wait time is doubled for subsequent attempts (including a bit of
|
/// The wait time is doubled for subsequent attempts (including a bit of
|
||||||
|
@ -853,8 +872,8 @@ impl RustyPipeBuilder {
|
||||||
///
|
///
|
||||||
/// **Default value**: 2
|
/// **Default value**: 2
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn n_request_attempts(mut self, n_retries: u32) -> Self {
|
pub fn n_http_retries(mut self, n_retries: u32) -> Self {
|
||||||
self.n_request_attempts = n_retries.max(1);
|
self.n_http_retries = n_retries.max(1);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1010,6 +1029,18 @@ impl RustyPipeBuilder {
|
||||||
self
|
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
|
/// Enable caching for session-bound PO tokens
|
||||||
///
|
///
|
||||||
/// By default, RustyPipe calls Botguard for every player request to fetch both a
|
/// By default, RustyPipe calls Botguard for every player request to fetch both a
|
||||||
|
@ -1060,7 +1091,7 @@ impl RustyPipe {
|
||||||
/// Execute the given http request.
|
/// Execute the given http request.
|
||||||
async fn http_request(&self, request: &Request) -> Result<Response, reqwest::Error> {
|
async fn http_request(&self, request: &Request) -> Result<Response, reqwest::Error> {
|
||||||
let mut last_resp = None;
|
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 resp = self.inner.http.execute(request.try_clone().unwrap()).await;
|
||||||
|
|
||||||
let err = match resp {
|
let err = match resp {
|
||||||
|
@ -1086,7 +1117,7 @@ impl RustyPipe {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Retry in case of a recoverable status code (server err, too many requests)
|
// 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);
|
let ms = util::retry_delay(n, 1000, 60000, 3);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Retry attempt #{}. Error: {}. Waiting {} ms",
|
"Retry attempt #{}. Error: {}. Waiting {} ms",
|
||||||
|
@ -1674,6 +1705,11 @@ impl RustyPipe {
|
||||||
);
|
);
|
||||||
Ok(())
|
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 {
|
impl RustyPipeQuery {
|
||||||
|
@ -2152,7 +2188,7 @@ impl RustyPipeQuery {
|
||||||
self.client.inner.visitor_data_cache.remove(visitor_data);
|
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> {
|
async fn get_po_tokens(&self, idents: &[&str]) -> Result<(Vec<String>, OffsetDateTime), Error> {
|
||||||
let bg = self
|
let bg = self
|
||||||
.client
|
.client
|
||||||
|
@ -2191,28 +2227,41 @@ impl RustyPipeQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut valid_until = None;
|
let mut valid_until = None;
|
||||||
|
let mut from_snapshot = false;
|
||||||
for word in words {
|
for word in words {
|
||||||
if let Some((k, v)) = word.split_once('=') {
|
if let Some((k, v)) = word.split_once('=') {
|
||||||
if k == "valid_until" {
|
match k {
|
||||||
valid_until = Some(
|
"valid_until" => {
|
||||||
v.parse::<i64>()
|
valid_until = Some(
|
||||||
.ok()
|
v.parse::<i64>()
|
||||||
.and_then(|x| OffsetDateTime::from_unix_timestamp(x).ok())
|
.ok()
|
||||||
.ok_or(ExtractionError::Botguard(
|
.and_then(|x| OffsetDateTime::from_unix_timestamp(x).ok())
|
||||||
format!("invalid validity date: {v}").into(),
|
.ok_or(ExtractionError::Botguard(
|
||||||
))?,
|
format!("invalid validity date: {v}").into(),
|
||||||
);
|
))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"from_snapshot" => {
|
||||||
|
from_snapshot = v.eq_ignore_ascii_case("true") || v == "1";
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("generated PO token (took {:?})", start.elapsed());
|
let valid_until =
|
||||||
Ok((
|
valid_until.unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::hours(12));
|
||||||
tokens,
|
|
||||||
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> {
|
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) {
|
if let Some(po_token) = self.client.inner.visitor_data_cache.get_pot(visitor_data) {
|
||||||
return Ok(po_token);
|
return Ok(po_token);
|
||||||
|
@ -2226,7 +2275,7 @@ impl RustyPipeQuery {
|
||||||
Ok(po_token)
|
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.
|
/// 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<
|
async fn execute_request_attempt<
|
||||||
R: DeserializeOwned + MapResponse<M> + Debug,
|
R: DeserializeOwned + MapResponse<M> + Debug,
|
||||||
M,
|
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<
|
async fn execute_request_inner<
|
||||||
R: DeserializeOwned + MapResponse<M> + Debug,
|
R: DeserializeOwned + MapResponse<M> + Debug,
|
||||||
M,
|
M,
|
||||||
|
@ -2344,7 +2413,7 @@ impl RustyPipeQuery {
|
||||||
ctx_src: &MapRespOptions<'_>,
|
ctx_src: &MapRespOptions<'_>,
|
||||||
) -> Result<RequestResult<M>, Error> {
|
) -> Result<RequestResult<M>, Error> {
|
||||||
let mut last_resp = None;
|
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
|
let resp = self
|
||||||
.execute_request_attempt::<R, M, B>(ctype, id, endpoint, body, ctx_src)
|
.execute_request_attempt::<R, M, B>(ctype, id, endpoint, body, ctx_src)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -2362,7 +2431,7 @@ impl RustyPipeQuery {
|
||||||
// Remove the used visitor data from cache if the request resulted in a recoverable error
|
// Remove the used visitor data from cache if the request resulted in a recoverable error
|
||||||
self.remove_visitor_data(&resp.visitor_data);
|
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);
|
let ms = util::retry_delay(n, 1000, 60000, 3);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Retry attempt #{}. Error: {}. Waiting {} ms",
|
"Retry attempt #{}. Error: {}. Waiting {} ms",
|
||||||
|
@ -2437,7 +2506,7 @@ impl RustyPipeQuery {
|
||||||
if level > Level::DBG || self.opts.report {
|
if level > Level::DBG || self.opts.report {
|
||||||
if let Some(reporter) = &self.client.inner.reporter {
|
if let Some(reporter) = &self.client.inner.reporter {
|
||||||
let report = Report {
|
let report = Report {
|
||||||
info: RustyPipeInfo::new(Some(self.opts.lang)),
|
info: self.rp_info(),
|
||||||
level,
|
level,
|
||||||
operation: &format!("{operation}({id})"),
|
operation: &format!("{operation}({id})"),
|
||||||
error,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
@ -122,20 +122,6 @@ impl RustyPipeQuery {
|
||||||
}
|
}
|
||||||
Ok(album)
|
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 {
|
impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
|
||||||
|
|
|
@ -8,7 +8,7 @@ use crate::{
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
model::{
|
model::{
|
||||||
paginator::{ContinuationEndpoint, Paginator},
|
paginator::{ContinuationEndpoint, Paginator},
|
||||||
AlbumItem, ArtistItem, HistoryItem, MusicPlaylistItem, TrackItem,
|
AlbumItem, ArtistItem, HistoryItem, MusicPlaylist, MusicPlaylistItem, TrackItem,
|
||||||
},
|
},
|
||||||
serializer::MapResult,
|
serializer::MapResult,
|
||||||
};
|
};
|
||||||
|
@ -127,6 +127,20 @@ impl RustyPipeQuery {
|
||||||
)
|
)
|
||||||
.await
|
.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 {
|
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicHistory {
|
||||||
|
@ -195,7 +209,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn map_history() {
|
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 json_file = File::open(json_path).unwrap();
|
||||||
|
|
||||||
let history: response::MusicHistory =
|
let history: response::MusicHistory =
|
|
@ -6,12 +6,15 @@ use crate::model::{
|
||||||
traits::FromYtItem,
|
traits::FromYtItem,
|
||||||
Comment, MusicItem, YouTubeItem,
|
Comment, MusicItem, YouTubeItem,
|
||||||
};
|
};
|
||||||
use crate::model::{HistoryItem, TrackItem, VideoItem};
|
|
||||||
use crate::serializer::MapResult;
|
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::{
|
use super::{
|
||||||
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
|
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 {
|
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
|
||||||
fn map_response(
|
fn map_response(
|
||||||
self,
|
self,
|
||||||
|
@ -270,6 +274,7 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicContinuation {
|
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicContinuation {
|
||||||
fn map_response(
|
fn map_response(
|
||||||
self,
|
self,
|
||||||
|
@ -422,6 +427,8 @@ impl Paginator<Comment> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
|
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
|
||||||
impl Paginator<HistoryItem<VideoItem>> {
|
impl Paginator<HistoryItem<VideoItem>> {
|
||||||
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
|
/// 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> {
|
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>> {
|
impl Paginator<HistoryItem<TrackItem>> {
|
||||||
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
|
/// 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> {
|
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
|
||||||
|
@ -533,7 +542,11 @@ macro_rules! paginator {
|
||||||
}
|
}
|
||||||
|
|
||||||
paginator!(Comment);
|
paginator!(Comment);
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
|
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
|
||||||
paginator!(HistoryItem<VideoItem>);
|
paginator!(HistoryItem<VideoItem>);
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
|
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
|
||||||
paginator!(HistoryItem<TrackItem>);
|
paginator!(HistoryItem<TrackItem>);
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -620,7 +633,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[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) {
|
fn map_continuation_channels(#[case] name: &str, #[case] path: PathBuf) {
|
||||||
let json_path = path!(*TESTFILES / path);
|
let json_path = path!(*TESTFILES / path);
|
||||||
let json_file = File::open(json_path).unwrap();
|
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::playlist_tracks("playlist_tracks", path!("music_playlist" / "playlist_cont.json"))]
|
||||||
#[case::search_tracks("search_tracks", path!("music_search" / "tracks_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::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) {
|
fn map_continuation_tracks(#[case] name: &str, #[case] path: PathBuf) {
|
||||||
let json_path = path!(*TESTFILES / path);
|
let json_path = path!(*TESTFILES / path);
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
@ -665,7 +678,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[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) {
|
fn map_continuation_artists(#[case] name: &str, #[case] path: PathBuf) {
|
||||||
let json_path = path!(*TESTFILES / path);
|
let json_path = path!(*TESTFILES / path);
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
@ -686,7 +699,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[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) {
|
fn map_continuation_albums(#[case] name: &str, #[case] path: PathBuf) {
|
||||||
let json_path = path!(*TESTFILES / path);
|
let json_path = path!(*TESTFILES / path);
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
@ -708,7 +721,7 @@ mod tests {
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[case::playlist_related("playlist_related", path!("music_playlist" / "playlist_related.json"))]
|
#[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) {
|
fn map_continuation_music_playlists(#[case] name: &str, #[case] path: PathBuf) {
|
||||||
let json_path = path!(*TESTFILES / path);
|
let json_path = path!(*TESTFILES / path);
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
|
|
@ -33,28 +33,6 @@ impl RustyPipeQuery {
|
||||||
)
|
)
|
||||||
.await
|
.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 {
|
impl MapResponse<Playlist> for response::Playlist {
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
pub(crate) mod channel;
|
pub(crate) mod channel;
|
||||||
pub(crate) mod history;
|
|
||||||
pub(crate) mod music_artist;
|
pub(crate) mod music_artist;
|
||||||
pub(crate) mod music_charts;
|
pub(crate) mod music_charts;
|
||||||
pub(crate) mod music_details;
|
pub(crate) mod music_details;
|
||||||
pub(crate) mod music_genres;
|
pub(crate) mod music_genres;
|
||||||
pub(crate) mod music_history;
|
|
||||||
pub(crate) mod music_item;
|
pub(crate) mod music_item;
|
||||||
pub(crate) mod music_new;
|
pub(crate) mod music_new;
|
||||||
pub(crate) mod music_playlist;
|
pub(crate) mod music_playlist;
|
||||||
|
@ -19,7 +17,6 @@ pub(crate) mod video_item;
|
||||||
|
|
||||||
pub(crate) use channel::Channel;
|
pub(crate) use channel::Channel;
|
||||||
pub(crate) use channel::ChannelAbout;
|
pub(crate) use channel::ChannelAbout;
|
||||||
pub(crate) use history::History;
|
|
||||||
pub(crate) use music_artist::MusicArtist;
|
pub(crate) use music_artist::MusicArtist;
|
||||||
pub(crate) use music_artist::MusicArtistAlbums;
|
pub(crate) use music_artist::MusicArtistAlbums;
|
||||||
pub(crate) use music_charts::MusicCharts;
|
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_details::MusicRelated;
|
||||||
pub(crate) use music_genres::MusicGenre;
|
pub(crate) use music_genres::MusicGenre;
|
||||||
pub(crate) use music_genres::MusicGenres;
|
pub(crate) use music_genres::MusicGenres;
|
||||||
pub(crate) use music_history::MusicHistory;
|
|
||||||
pub(crate) use music_item::MusicContinuation;
|
pub(crate) use music_item::MusicContinuation;
|
||||||
pub(crate) use music_new::MusicNew;
|
pub(crate) use music_new::MusicNew;
|
||||||
pub(crate) use music_playlist::MusicPlaylist;
|
pub(crate) use music_playlist::MusicPlaylist;
|
||||||
|
@ -51,6 +47,15 @@ pub(crate) mod channel_rss;
|
||||||
#[cfg(feature = "rss")]
|
#[cfg(feature = "rss")]
|
||||||
pub(crate) use channel_rss::ChannelRss;
|
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::borrow::Cow;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
|
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
|
||||||
use time::UtcOffset;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{
|
model::{
|
||||||
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
|
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
|
||||||
HistoryItem, MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
|
MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
|
||||||
},
|
},
|
||||||
param::Language,
|
param::Language,
|
||||||
serializer::{
|
serializer::{
|
||||||
|
@ -23,6 +22,11 @@ use super::{
|
||||||
SimpleHeaderRenderer, Thumbnails, ThumbnailsWrap,
|
SimpleHeaderRenderer, Thumbnails, ThumbnailsWrap,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
|
use crate::model::HistoryItem;
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
|
use time::UtcOffset;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) enum ItemSection {
|
pub(crate) enum ItemSection {
|
||||||
|
@ -40,6 +44,7 @@ pub(crate) enum ItemSection {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct MusicShelf {
|
pub(crate) struct MusicShelf {
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
#[serde_as(as = "Option<Text>")]
|
#[serde_as(as = "Option<Text>")]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
/// Playlist ID (only for playlists)
|
/// Playlist ID (only for playlists)
|
||||||
|
@ -1270,6 +1275,7 @@ impl MusicListMapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
pub fn conv_history_items(
|
pub fn conv_history_items(
|
||||||
self,
|
self,
|
||||||
date_txt: Option<String>,
|
date_txt: Option<String>,
|
||||||
|
|
|
@ -2,14 +2,11 @@ use serde::Deserialize;
|
||||||
use serde_with::{
|
use serde_with::{
|
||||||
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
|
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
|
||||||
};
|
};
|
||||||
use time::{OffsetDateTime, UtcOffset};
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use super::{
|
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails};
|
||||||
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
|
|
||||||
Thumbnails,
|
|
||||||
};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{Channel, ChannelItem, ChannelTag, HistoryItem, PlaylistItem, VideoItem, YouTubeItem},
|
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
|
||||||
param::Language,
|
param::Language,
|
||||||
serializer::{
|
serializer::{
|
||||||
text::{AttributedText, Text, TextComponent},
|
text::{AttributedText, Text, TextComponent},
|
||||||
|
@ -18,6 +15,11 @@ use crate::{
|
||||||
util::{self, timeago, TryRemove},
|
util::{self, timeago, TryRemove},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
|
use crate::{client::response::SimpleHeaderRenderer, model::HistoryItem};
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
|
use time::UtcOffset;
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
@ -66,6 +68,7 @@ pub(crate) enum YouTubeListItem {
|
||||||
/// GridRenderer: contains videos on channel page
|
/// GridRenderer: contains videos on channel page
|
||||||
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
|
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
|
||||||
ItemSectionRenderer {
|
ItemSectionRenderer {
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
header: Option<ItemSectionHeader>,
|
header: Option<ItemSectionHeader>,
|
||||||
#[serde(alias = "items")]
|
#[serde(alias = "items")]
|
||||||
contents: MapResult<Vec<YouTubeListItem>>,
|
contents: MapResult<Vec<YouTubeListItem>>,
|
||||||
|
@ -298,6 +301,7 @@ pub(crate) struct YouTubeListRenderer {
|
||||||
pub contents: MapResult<Vec<YouTubeListItem>>,
|
pub contents: MapResult<Vec<YouTubeListItem>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct ItemSectionHeader {
|
pub(crate) struct ItemSectionHeader {
|
||||||
|
@ -904,6 +908,7 @@ impl YouTubeListMapper<VideoItem> {
|
||||||
res.c.into_iter().for_each(|item| self.map_item(item));
|
res.c.into_iter().for_each(|item| self.map_item(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
pub(crate) fn conv_history_items(
|
pub(crate) fn conv_history_items(
|
||||||
self,
|
self,
|
||||||
date_txt: Option<String>,
|
date_txt: Option<String>,
|
||||||
|
|
|
@ -7,7 +7,7 @@ use crate::{
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
model::{
|
model::{
|
||||||
paginator::{ContinuationEndpoint, Paginator},
|
paginator::{ContinuationEndpoint, Paginator},
|
||||||
ChannelItem, HistoryItem, PlaylistItem, VideoItem,
|
ChannelItem, HistoryItem, Playlist, PlaylistItem, VideoItem,
|
||||||
},
|
},
|
||||||
serializer::MapResult,
|
serializer::MapResult,
|
||||||
};
|
};
|
||||||
|
@ -148,6 +148,28 @@ impl RustyPipeQuery {
|
||||||
)
|
)
|
||||||
.await
|
.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 {
|
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::History {
|
||||||
|
@ -258,7 +280,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn map_history() {
|
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 json_file = File::open(json_path).unwrap();
|
||||||
|
|
||||||
let history: response::History =
|
let history: response::History =
|
||||||
|
@ -278,7 +300,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn map_subscription_feed() {
|
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 json_file = File::open(json_path).unwrap();
|
||||||
|
|
||||||
let history: response::History =
|
let history: response::History =
|
|
@ -39,7 +39,7 @@ impl DeobfData {
|
||||||
if let Err(e) = &res {
|
if let Err(e) = &res {
|
||||||
if let Some(reporter) = reporter {
|
if let Some(reporter) = reporter {
|
||||||
let report = Report {
|
let report = Report {
|
||||||
info: RustyPipeInfo::new(None),
|
info: RustyPipeInfo::new(None, None),
|
||||||
level: Level::ERR,
|
level: Level::ERR,
|
||||||
operation: "extract_deobf",
|
operation: "extract_deobf",
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
|
|
@ -70,6 +70,8 @@ pub struct RustyPipeInfo<'a> {
|
||||||
/// YouTube content language
|
/// YouTube content language
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<Language>,
|
pub language: Option<Language>,
|
||||||
|
/// RustyPipe Botguard version (`rustypipe-botguard 0.1.1`)
|
||||||
|
pub botguard_version: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reported HTTP request data
|
/// Reported HTTP request data
|
||||||
|
@ -104,13 +106,14 @@ pub enum Level {
|
||||||
ERR,
|
ERR,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RustyPipeInfo<'_> {
|
impl<'a> RustyPipeInfo<'a> {
|
||||||
pub(crate) fn new(language: Option<Language>) -> Self {
|
pub(crate) fn new(language: Option<Language>, botguard_version: Option<&'a str>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
package: env!("CARGO_PKG_NAME"),
|
package: env!("CARGO_PKG_NAME"),
|
||||||
version: crate::VERSION,
|
version: crate::VERSION,
|
||||||
date: util::now_sec(),
|
date: util::now_sec(),
|
||||||
language,
|
language,
|
||||||
|
botguard_version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ use regex::Regex;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{AuthError, Error, ExtractionError},
|
error::Error,
|
||||||
param::{Country, Language, COUNTRIES},
|
param::{Country, Language, COUNTRIES},
|
||||||
serializer::text::TextComponent,
|
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.
|
/// Generate a random string with given length and byte charset.
|
||||||
fn random_string(charset: &[u8], length: usize) -> String {
|
fn random_string(charset: &[u8], length: usize) -> String {
|
||||||
let mut result = String::with_capacity(length);
|
let mut result = String::with_capacity(length);
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
for _ in 0..length {
|
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
|
result
|
||||||
|
@ -90,14 +90,14 @@ pub fn generate_content_playback_nonce() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn random_uuid() -> String {
|
pub fn random_uuid() -> String {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::thread_rng();
|
||||||
format!(
|
format!(
|
||||||
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
|
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
|
||||||
rng.random::<u32>(),
|
rng.gen::<u32>(),
|
||||||
rng.random::<u16>(),
|
rng.gen::<u16>(),
|
||||||
rng.random::<u16>(),
|
rng.gen::<u16>(),
|
||||||
rng.random::<u16>(),
|
rng.gen::<u16>(),
|
||||||
rng.random::<u64>() & 0xffff_ffff_ffff,
|
rng.gen::<u64>() & 0xffff_ffff_ffff,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -229,7 +229,7 @@ pub fn retry_delay(
|
||||||
backoff_base: u32,
|
backoff_base: u32,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
let unjittered_delay = backoff_base.checked_pow(n_past_retries).unwrap_or(u32::MAX);
|
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
|
let jittered_delay = unjittered_delay
|
||||||
.checked_mul(jitter_factor)
|
.checked_mul(jitter_factor)
|
||||||
.unwrap_or(u32::MAX);
|
.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
|
/// If no user is logged in, YouTube returns a "NotFound" error. This has to be corrected
|
||||||
/// into a NoLogin error.
|
/// into a NoLogin error.
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
pub fn map_internal_playlist_err(e: Error) -> Error {
|
pub fn map_internal_playlist_err(e: Error) -> Error {
|
||||||
if let Error::Extraction(ExtractionError::NotFound { .. }) = e {
|
if let Error::Extraction(crate::error::ExtractionError::NotFound { .. }) = e {
|
||||||
Error::Auth(AuthError::NoLogin)
|
Error::Auth(crate::error::AuthError::NoLogin)
|
||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
/// 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.
|
/// Returns None if the date could not be parsed.
|
||||||
|
#[cfg(feature = "userdata")]
|
||||||
pub fn parse_textual_date_to_d(
|
pub fn parse_textual_date_to_d(
|
||||||
lang: Language,
|
lang: Language,
|
||||||
utc_offset: UtcOffset,
|
utc_offset: UtcOffset,
|
||||||
|
|
|
@ -148,8 +148,8 @@ impl VisitorDataCache {
|
||||||
{
|
{
|
||||||
let vds = self.inner.visitor_data.read().unwrap();
|
let vds = self.inner.visitor_data.read().unwrap();
|
||||||
if !vds.is_empty() {
|
if !vds.is_empty() {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::thread_rng();
|
||||||
let vd = vds[rng.random_range(0..vds.len())].to_owned();
|
let vd = vds[rng.gen_range(0..vds.len())].to_owned();
|
||||||
tracing::debug!("visitor data {vd} picked from cache");
|
tracing::debug!("visitor data {vd} picked from cache");
|
||||||
return Ok(vd);
|
return Ok(vd);
|
||||||
}
|
}
|
||||||
|
@ -181,7 +181,7 @@ impl VisitorDataCache {
|
||||||
pub fn get_pot(&self, visitor_data: &str) -> Option<PoToken> {
|
pub fn get_pot(&self, visitor_data: &str) -> Option<PoToken> {
|
||||||
let pots = self.inner.session_potoken.read().unwrap();
|
let pots = self.inner.session_potoken.read().unwrap();
|
||||||
if let Some(entry) = pots.get(visitor_data) {
|
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());
|
return Some(entry.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -146,21 +146,6 @@ MusicArtist(
|
||||||
year: Some(2015),
|
year: Some(2015),
|
||||||
by_va: false,
|
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(
|
AlbumItem(
|
||||||
id: "MPREb_h0UZr2ALQXf",
|
id: "MPREb_h0UZr2ALQXf",
|
||||||
name: "From Parts Unknown (Deluxe Edition)",
|
name: "From Parts Unknown (Deluxe Edition)",
|
||||||
|
|
|
@ -5,7 +5,7 @@ use std::fmt::Display;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
use rustypipe::model::{HistoryItem, TrackItem, TrackType, VideoItem};
|
use rustypipe::model::TrackType;
|
||||||
use rustypipe::param::{AlbumOrder, LANGUAGES};
|
use rustypipe::param::{AlbumOrder, LANGUAGES};
|
||||||
use time::{macros::date, OffsetDateTime};
|
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 super::*;
|
||||||
|
|
||||||
|
use rustypipe::model::{HistoryItem, TrackItem, VideoItem};
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn history(rp: RustyPipe) {
|
async fn history(rp: RustyPipe) {
|
||||||
|
@ -2814,6 +2817,30 @@ mod cookie_auth {
|
||||||
let tracks = rp.query().music_liked_tracks().await.unwrap();
|
let tracks = rp.query().music_liked_tracks().await.unwrap();
|
||||||
assert_next_items(tracks.tracks, rp.query(), 5).await;
|
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]
|
#[rstest]
|
||||||
|
@ -2940,30 +2967,6 @@ async fn assert_next_items<T: FromYtItem, Q: AsRef<RustyPipeQuery>>(
|
||||||
assert_gte(p.items.len(), n_items, "items");
|
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]
|
#[track_caller]
|
||||||
fn assert_frameset(frameset: &Frameset) {
|
fn assert_frameset(frameset: &Frameset) {
|
||||||
assert_gte(frameset.frame_height, 20, "frame height");
|
assert_gte(frameset.frame_height, 20, "frame height");
|
||||||
|
@ -3025,10 +3028,6 @@ async fn all_send_and_sync() {
|
||||||
rp.query()
|
rp.query()
|
||||||
.drm_license("", rustypipe::model::DrmSystem::Widevine, "", "", &[]),
|
.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_album(""));
|
||||||
send_and_sync(rp.query().music_artist("", false));
|
send_and_sync(rp.query().music_artist("", false));
|
||||||
send_and_sync(rp.query().music_artist_albums("", None, None));
|
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_details(""));
|
||||||
send_and_sync(rp.query().music_genre(""));
|
send_and_sync(rp.query().music_genre(""));
|
||||||
send_and_sync(rp.query().music_genres());
|
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_lyrics(""));
|
||||||
send_and_sync(rp.query().music_new_albums());
|
send_and_sync(rp.query().music_new_albums());
|
||||||
send_and_sync(rp.query().music_new_videos());
|
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_playlist(""));
|
||||||
send_and_sync(rp.query().music_radio_track(""));
|
send_and_sync(rp.query().music_radio_track(""));
|
||||||
send_and_sync(rp.query().music_related(""));
|
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::<MusicItem, _>("", None));
|
||||||
send_and_sync(rp.query().music_search_albums(""));
|
send_and_sync(rp.query().music_search_albums(""));
|
||||||
send_and_sync(rp.query().music_search_artists(""));
|
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().raw(ClientType::Desktop, "", ""));
|
||||||
send_and_sync(rp.query().resolve_string("", false));
|
send_and_sync(rp.query().resolve_string("", false));
|
||||||
send_and_sync(rp.query().resolve_url("", 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::<YouTubeItem, _>(""));
|
||||||
send_and_sync(
|
send_and_sync(
|
||||||
rp.query()
|
rp.query()
|
||||||
.search_filter::<YouTubeItem, _>("", &SearchFilter::default()),
|
.search_filter::<YouTubeItem, _>("", &SearchFilter::default()),
|
||||||
);
|
);
|
||||||
send_and_sync(rp.query().search_suggestion(""));
|
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().trending());
|
||||||
send_and_sync(rp.query().video_comments("", None));
|
send_and_sync(rp.query().video_comments("", None));
|
||||||
send_and_sync(rp.query().video_details(""));
|
send_and_sync(rp.query().video_details(""));
|
||||||
send_and_sync(rp.query().watch_later());
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue