Compare commits

..

2 commits

Author SHA1 Message Date
235017ba29 feat: add ogg postprocessor to downloader
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-15 12:11:38 +02:00
d9c45bb2e0 feat: add ogg_from_webm postprocessor 2023-05-15 12:11:38 +02:00
323 changed files with 70749 additions and 557240 deletions

View file

@ -1,68 +0,0 @@
name: CI
on:
push:
branches: ["main"]
pull_request:
jobs:
Test:
runs-on: cimaster-latest
services:
warpproxy:
image: thetadev256/warpproxy
env:
WARP_DEVICE_ID: ${{ secrets.WARP_DEVICE_ID }}
WARP_ACCESS_TOKEN: ${{ secrets.WARP_ACCESS_TOKEN }}
WARP_LICENSE_KEY: ${{ secrets.WARP_LICENSE_KEY }}
WARP_PRIVATE_KEY: ${{ secrets.WARP_PRIVATE_KEY }}
steps:
- name: 📦 Checkout repository
uses: actions/checkout@v4
- name: 🦀 Setup Rust cache
uses: https://github.com/Swatinem/rust-cache@v2
with:
cache-on-failure: "true"
- name: Download rustypipe-botguard
run: |
TARGET=$(rustc --version --verbose | grep "host:" | sed -e 's/^host: //')
cd ~
curl -SsL -o rustypipe-botguard.tar.xz "https://codeberg.org/ThetaDev/rustypipe-botguard/releases/download/v0.1.1/rustypipe-botguard-v0.1.1-${TARGET}.tar.xz"
cd /usr/local/bin
sudo tar -xJf ~/rustypipe-botguard.tar.xz
rm ~/rustypipe-botguard.tar.xz
rustypipe-botguard --version
- name: 📎 Clippy
run: |
cargo clippy --all --tests --features=rss,userdata,indicatif,audiotag -- -D warnings
cargo clippy --package=rustypipe --tests -- -D warnings
cargo clippy --package=rustypipe-downloader -- -D warnings
cargo clippy --package=rustypipe-cli -- -D warnings
cargo clippy --package=rustypipe-cli --features=timezone -- -D warnings
- name: 🧪 Test
run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 --features rss,userdata --workspace -- --skip 'user_data::'
env:
ALL_PROXY: "http://warpproxy:8124"
- name: Move test report
if: always()
run: mv target/nextest/ci/junit.xml junit.xml || true
- name: 💌 Upload test report
if: always()
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: test
path: |
junit.xml
rustypipe_reports
- name: 🔗 Artifactview PR comment
if: ${{ always() && github.event_name == 'pull_request' }}
run: |
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \
--data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}'

View file

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

View file

@ -1,34 +0,0 @@
name: Release
on:
push:
tags:
- "*/v*.*.*"
jobs:
Release:
runs-on: cimaster-latest
steps:
- name: 📦 Checkout repository
uses: actions/checkout@v4
- name: Get variables
run: |
CRATE=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==1{print}')
echo "CRATE=$CRATE" >> "$GITHUB_ENV"
echo "CRATE_VERSION=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==2{print}')" >> "$GITHUB_ENV"
CL_PATH="CHANGELOG.md"
if [[ "$CRATE" != "rustypipe" ]]; then pfx="rustypipe-"; CL_PATH="${CRATE#"$pfx"}/$CL_PATH"; fi
{
echo 'CHANGELOG<<END_OF_FILE'
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CL_PATH"
echo END_OF_FILE
} >> "$GITHUB_ENV"
- name: 📤 Publish crate on crates.io
run: cargo publish --token ${{ secrets.CARGO_TOKEN }} --package "${{ env.CRATE }}"
- name: 🎉 Publish release
uses: https://gitea.com/actions/release-action@main
with:
title: "${{ env.CRATE }} ${{ env.CRATE_VERSION }}"
body: "${{ env.CHANGELOG }}"

View file

@ -1,63 +0,0 @@
name: renovate
on:
push:
branches: ["main"]
paths:
- ".forgejo/workflows/renovate.yaml"
- "renovate.json"
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
env:
RENOVATE_REPOSITORIES: ${{ github.repository }}
jobs:
renovate:
runs-on: docker
container:
image: renovate/renovate:39
steps:
- name: Load renovate repo cache
uses: actions/cache/restore@v4
with:
path: |
.tmp/cache/renovate/repository
.tmp/cache/renovate/renovate-cache-sqlite
.tmp/osv
key: repo-cache-${{ github.run_id }}
restore-keys: |
repo-cache-
- name: Run renovate
run: renovate
env:
LOG_LEVEL: debug
RENOVATE_BASE_DIR: ${{ github.workspace }}/.tmp
RENOVATE_ENDPOINT: ${{ github.server_url }}
RENOVATE_PLATFORM: gitea
RENOVATE_REPOSITORY_CACHE: 'enabled'
RENOVATE_TOKEN: ${{ secrets.FORGEJO_CI_BOT_TOKEN }}
GITHUB_COM_TOKEN: ${{ secrets.GH_PUBLIC_TOKEN }}
RENOVATE_GIT_AUTHOR: 'Renovate Bot <forgejo-renovate-action@forgejo.org>'
RENOVATE_X_SQLITE_PACKAGE_CACHE: true
GIT_AUTHOR_NAME: 'Renovate Bot'
GIT_AUTHOR_EMAIL: 'forgejo-renovate-action@forgejo.org'
GIT_COMMITTER_NAME: 'Renovate Bot'
GIT_COMMITTER_EMAIL: 'forgejo-renovate-action@forgejo.org'
OSV_OFFLINE_ROOT_DIR: ${{ github.workspace }}/.tmp/osv
- name: Save renovate repo cache
if: always() && env.RENOVATE_DRY_RUN != 'full'
uses: actions/cache/save@v4
with:
path: |
.tmp/cache/renovate/repository
.tmp/cache/renovate/renovate-cache-sqlite
.tmp/osv
key: repo-cache-${{ github.run_id }}

3
.gitignore vendored
View file

@ -4,5 +4,4 @@
*.snap.new *.snap.new
rustypipe_reports rustypipe_reports
rustypipe_cache*.json rustypipe_cache.json
bg_snapshot.bin

View file

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v4.3.0
hooks: hooks:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-json - id: check-json
@ -10,8 +10,4 @@ repos:
hooks: hooks:
- id: cargo-fmt - id: cargo-fmt
- id: cargo-clippy - id: cargo-clippy
name: cargo-clippy rustypipe args: ["--all", "--features=rss", "--", "-D", "warnings"]
args: ["--package=rustypipe", "--tests", "--", "-D", "warnings"]
- id: cargo-clippy
name: cargo-clippy workspace
args: ["--all", "--tests", "--features=rss,userdata,indicatif,audiotag", "--", "-D", "warnings"]

10
.woodpecker.yml Normal file
View file

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

View file

@ -1,336 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v0.10.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.9.0..rustypipe/v0.10.0) - 2025-02-09
### 🚀 Features
- Add visitor data cache, remove random visitor data - ([b12f4c5](https://codeberg.org/ThetaDev/rustypipe/commit/b12f4c5d821a9189d7ed8410ad860824b6d052ef))
- Add support for rustypipe-botguard to get PO tokens - ([b90a252](https://codeberg.org/ThetaDev/rustypipe/commit/b90a252a5e1bf05a5294168b0ec16a73cbb88f42))
- Add session po token cache - ([b72b501](https://codeberg.org/ThetaDev/rustypipe/commit/b72b501b6dbcf4333b24cd80e7c8c61b0c21ec91))
- Check rustypipe-botguard-api version - ([8385b87](https://codeberg.org/ThetaDev/rustypipe/commit/8385b87c63677f32a240679a78702f53072e517a))
- Rewrite request attempt system, retry with different visitor data - ([dfd03ed](https://codeberg.org/ThetaDev/rustypipe/commit/dfd03edfadff2657e9cfbf04e5d313ba409520ac))
- Log failed player fetch attempts with player_from_clients - ([8e35358](https://codeberg.org/ThetaDev/rustypipe/commit/8e35358c8941301f6ebf7646a11ab22711082569))
- Add timezone query option - ([3a2370b](https://codeberg.org/ThetaDev/rustypipe/commit/3a2370b97ca3d0f40d72d66a23295557317d29fb))
- [**breaking**] Add userdata feature for all personal data queries (playback history, subscriptions) - ([65cb424](https://codeberg.org/ThetaDev/rustypipe/commit/65cb4244c6ab547f53d0cb12af802c4189188c86))
- Add RustyPipe::version_botguard fn, detect rustypipe-botguard in current dir, add botguard version to report - ([1d755b7](https://codeberg.org/ThetaDev/rustypipe/commit/1d755b76bf4569f7d0bb90a65494ac8e7aae499a))
### 🐛 Bug Fixes
- Parsing history dates - ([af7dc10](https://codeberg.org/ThetaDev/rustypipe/commit/af7dc1016322a87dd8fec0b739939c2b12b6f400))
- A/V streams incorrectly recognized as video-only - ([2b891ca](https://codeberg.org/ThetaDev/rustypipe/commit/2b891ca0788f91f16dbb9203191cb3d2092ecc74))
- Update iOS client - ([e915416](https://codeberg.org/ThetaDev/rustypipe/commit/e91541629d6c944c1001f5883e3c1264aeeb3969))
- A/B test 20: music continuation item renderer - ([9c67f8f](https://codeberg.org/ThetaDev/rustypipe/commit/9c67f8f85bef8214848dc9d17bff6cff252e015e))
- Include whole request body in report - ([15245c1](https://codeberg.org/ThetaDev/rustypipe/commit/15245c18b584e42523762b94fcc7284d483660a0))
- Extracting nsig fn when outside variable starts with $ - ([eda16e3](https://codeberg.org/ThetaDev/rustypipe/commit/eda16e378730a3b57c4982a626df1622a93c574a))
- Retry updating deobf data after a RustyPipe update - ([50ab1f7](https://codeberg.org/ThetaDev/rustypipe/commit/50ab1f7a5d8aeaa3720264b4a4b27805bb0e8121))
- Allow player data to be fetched without botguard - ([29c854b](https://codeberg.org/ThetaDev/rustypipe/commit/29c854b20d7a6677415b1744e7ba7ecd4f594ea5))
- Output full request body in reports, clean up `get_player_po_token` - ([a0d850f](https://codeberg.org/ThetaDev/rustypipe/commit/a0d850f8e01428a73bbd66397d0dbf797b45958f))
- Correct timezone offset for parsed dates, add timezone_local option - ([a5a7be5](https://codeberg.org/ThetaDev/rustypipe/commit/a5a7be5b4e0a0b73d7e1dc802ebd7bd48dafc76d))
- Use localzone crate to get local tz - ([5acbf0e](https://codeberg.org/ThetaDev/rustypipe/commit/5acbf0e456b1f10707e0a56125d993a8129eee3a))
- Only use cached potokens with min. 10min lifetime - ([0c94267](https://codeberg.org/ThetaDev/rustypipe/commit/0c94267d0371b2b26c7b5c9abfa156d5cde2153e))
### 📚 Documentation
- Add Botguard info to README - ([9957add](https://codeberg.org/ThetaDev/rustypipe/commit/9957add2b5d6391b2c1869d2019fd7dd91b8cd41))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rquickjs to 0.9.0 (#33) - ([2c8ac41](https://codeberg.org/ThetaDev/rustypipe/commit/2c8ac410aa535d83f8bcc7181f81914b13bceb77))
## [v0.9.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.8.0..rustypipe/v0.9.0) - 2025-01-16
### 🚀 Features
- Add functions to fetch a user's history and subscriptions - ([14e3995](https://codeberg.org/ThetaDev/rustypipe/commit/14e399594f97a1228a8c2991a14dd8745af1beb7))
- Add history item dates, extend timeago parser - ([320a8c2](https://codeberg.org/ThetaDev/rustypipe/commit/320a8c2c24217ad5697f0424c4f994bbbe31f3aa))
- Add session headers when using cookie auth - ([3c95b52](https://codeberg.org/ThetaDev/rustypipe/commit/3c95b52ceaf0df2d67ee0d2f2ac658f666f29836))
- Add cookies.txt parser, add cookie auth + history cmds to CLI - ([cf498e4](https://codeberg.org/ThetaDev/rustypipe/commit/cf498e4a8f9318b0197bc3f0cbaf7043c53adb9d))
- Add method to get saved_playlists - ([27f64fc](https://codeberg.org/ThetaDev/rustypipe/commit/27f64fc412e833d5bd19ad72913aae19358e98b9))
- Extract player DRM data - ([2af4001](https://codeberg.org/ThetaDev/rustypipe/commit/2af4001c75f2ff4f7c891aa59ac22c2c6b7902a2))
- Add Dolby audio codecs (ac-3, ec-3) - ([a7f8c78](https://codeberg.org/ThetaDev/rustypipe/commit/a7f8c789b1a34710274c4630e027ef868397aea2))
- Add DRM and audio channel number filtering to StreamFilter - ([d5abee2](https://codeberg.org/ThetaDev/rustypipe/commit/d5abee275300ab1bc10fc8d6c35a4e3813fd2bd4))
- Set cache file permissions to 600 - ([dee8a99](https://codeberg.org/ThetaDev/rustypipe/commit/dee8a99e7a8d071c987709a01f02ee8fecf2d776))
### 🐛 Bug Fixes
- Dont leak authorization and cookie header in reports - ([75fce91](https://codeberg.org/ThetaDev/rustypipe/commit/75fce91353c02cd498f27d21b08261c23ea03d70))
- Require new time crate version which added Month::length - ([ec7a195](https://codeberg.org/ThetaDev/rustypipe/commit/ec7a195c98f39346c4c8db875212c3843580450e))
- Parsing numbers (it), dates (kn) - ([63f86b6](https://codeberg.org/ThetaDev/rustypipe/commit/63f86b6e186aa1d2dcaf7e9169ccebb2265e5905))
- Accept user-specific playlist ids (LL, WL) - ([97c3f30](https://codeberg.org/ThetaDev/rustypipe/commit/97c3f30d180d3e62b7e19f22d191d7fd7614daca))
- Only use auth-enabled clients for fetching player with auth option enabled - ([2b2b4af](https://codeberg.org/ThetaDev/rustypipe/commit/2b2b4af0b26cdd0d4bf2218d3f527abd88658abf))
- A/B test 19: Music artist album groups reordered - ([5daad1b](https://codeberg.org/ThetaDev/rustypipe/commit/5daad1b700e8dcf1f3e803db1685f08f27794898))
- Switch to rquickjs crate for deobfuscator - ([75c3746](https://codeberg.org/ThetaDev/rustypipe/commit/75c3746890f3428f3314b7b10c9ec816ad275836))
- Player_from_clients method not send/sync - ([9c512c3](https://codeberg.org/ThetaDev/rustypipe/commit/9c512c3c4dbec0fc3b973536733d61ba61125a92))
### 📚 Documentation
- Update README - ([0432477](https://codeberg.org/ThetaDev/rustypipe/commit/0432477451ecd5f64145d65239c721f4e44826c0))
- Fix README - ([11442df](https://codeberg.org/ThetaDev/rustypipe/commit/11442dfd369599396357f5b7a7a4268a7b537f57))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rstest to 0.24.0 (#20) - ([ab19034](https://codeberg.org/ThetaDev/rustypipe/commit/ab19034ab19baf090e83eada056559676ffdadce))
- *(deps)* Update rust crate dirs to v6 (#24) - ([6a60425](https://codeberg.org/ThetaDev/rustypipe/commit/6a604252b1af7a9388db5dc170f737069cc31051))
- Update pre-commit hooks - ([7cd9246](https://codeberg.org/ThetaDev/rustypipe/commit/7cd9246260493d7839018cb39a2dfb4dded8b343))
## [v0.8.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.7.2..rustypipe/v0.8.0) - 2024-12-20
### 🚀 Features
- Log warning when generating report - ([258f18a](https://codeberg.org/ThetaDev/rustypipe/commit/258f18a99d848ae7e6808beddad054037a3b3799))
- Add auto-dubbed audio tracks, improved StreamFilter - ([1d1ae17](https://codeberg.org/ThetaDev/rustypipe/commit/1d1ae17ffc16724667d43142aa57abda2e6468e4))
### 🐛 Bug Fixes
- Replace deprecated call to `time::util::days_in_year_month` - ([69ef6ae](https://codeberg.org/ThetaDev/rustypipe/commit/69ef6ae51e9b09a9b9c06057e717bf6f054c9803))
- Nsig fn extra variable extraction - ([8014741](https://codeberg.org/ThetaDev/rustypipe/commit/80147413ee3190bb530f8f6b02738bcc787a6444))
- Deobf function extraction, allow $ in variable names - ([8cadbc1](https://codeberg.org/ThetaDev/rustypipe/commit/8cadbc1a4c865d085e30249dba0f353472456a32))
- Remove leading zero-width-space from comments, ensure space after links - ([162959c](https://codeberg.org/ThetaDev/rustypipe/commit/162959ca4513a03496776fae905b4bf20c79899c))
- Update client versions, enable Opus audio with iOS client - ([1b60c97](https://codeberg.org/ThetaDev/rustypipe/commit/1b60c97a183b9d74b92df14b5b113c61aba1be7f))
- Extract transcript from comment voice replies - ([30f60c3](https://codeberg.org/ThetaDev/rustypipe/commit/30f60c30f9d87d39585db93c1c9e274f48d688ba))
- Error 400 when fetching player with login - ([5ce84c4](https://codeberg.org/ThetaDev/rustypipe/commit/5ce84c44a6844f692258066c83e04df875e0aa91))
### ⚙️ Miscellaneous Tasks
- Update user agent - ([53e5846](https://codeberg.org/ThetaDev/rustypipe/commit/53e5846286e8db920622152c2a0a57ddc7c41d25))
## [v0.7.2](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.7.1..rustypipe/v0.7.2) - 2024-12-13
### 🐛 Bug Fixes
- Replace futures dependency with futures-util - ([5c39bf4](https://codeberg.org/ThetaDev/rustypipe/commit/5c39bf4842b13d37a4277ea5506e15c179892ce5))
- Lifetime-related lints - ([c4feff3](https://codeberg.org/ThetaDev/rustypipe/commit/c4feff37a5989097b575c43d89c26427d92d77b9))
- Limit retry attempts to fetch client versions and deobf data - ([44ae456](https://codeberg.org/ThetaDev/rustypipe/commit/44ae456d2c654679837da8ec44932c44b1b01195))
- Deobfuscation function extraction - ([f5437aa](https://codeberg.org/ThetaDev/rustypipe/commit/f5437aa127b2b7c5a08839643e30ea1ec989d30b))
## [v0.7.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.7.0..rustypipe/v0.7.1) - 2024-11-25
### 🐛 Bug Fixes
- Disable Android client - ([a846b72](https://codeberg.org/ThetaDev/rustypipe/commit/a846b729e3519e3d5e62bdf028d9b48a7f8ea2ce))
- A/B test 18: music playlist facepile avatar model - ([6c8108c](https://codeberg.org/ThetaDev/rustypipe/commit/6c8108c94acf9ca2336381bdca7c97b24a809521))
### ⚙️ Miscellaneous Tasks
- Add docs badge to README - ([706e881](https://codeberg.org/ThetaDev/rustypipe/commit/706e88134c0e94ce7d880735e9d31b3ff531a4f9))
## [v0.7.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.6.0..rustypipe/v0.7.0) - 2024-11-10
### 🚀 Features
- Allow searching for YTM users - ([50010b7](https://codeberg.org/ThetaDev/rustypipe/commit/50010b7b0856d3ce05fe7a9d5989e526089bc2ef))
- [**breaking**] Replace `TrackItem::is_video` attr with TrackType enum; serde lowercase AlbumType enum for consistency - ([044094a](https://codeberg.org/ThetaDev/rustypipe/commit/044094a4b70f05c46a459fa1597e23f4224b7b0b))
### 🐛 Bug Fixes
- Fetch unlocalized player data to interpret errors correctly; regression introduced with v0.6.0 - ([0919cbd](https://codeberg.org/ThetaDev/rustypipe/commit/0919cbd0dfe28ea00610c67a694e5f319e80635f))
- A/B test 17: channel playlists lockupViewModel - ([342119d](https://codeberg.org/ThetaDev/rustypipe/commit/342119dba6f3dc2152eef1fc9841264a9e56b9f0))
- [**breaking**] Serde: lowercase Verification enum - ([badb3ae](https://codeberg.org/ThetaDev/rustypipe/commit/badb3aef8249315909160b8ff73df3019f07cf97))
- Parsing videos using LockupViewModel (Music video recommendations) - ([870ff79](https://codeberg.org/ThetaDev/rustypipe/commit/870ff79ee07dfab1f4f2be3a401cd5320ed587da))
- Parsing lockup playlists with "MIX" instead of view count - ([ac8fbc3](https://codeberg.org/ThetaDev/rustypipe/commit/ac8fbc3e679819189e2791c323975acaf1b43035))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate thiserror to v2 (#16) - ([e1e1687](https://codeberg.org/ThetaDev/rustypipe/commit/e1e1687605603686ac5fd5deeb6aa8fecaf92494))
## [v0.6.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.5.0..rustypipe/v0.6.0) - 2024-10-28
### 🚀 Features
- [**breaking**] Remove TvHtml5Embed client as it got disabled - ([9e835c8](https://codeberg.org/ThetaDev/rustypipe/commit/9e835c8f38a3dd28c65561b2f9bb7a0f530c24f1))
- [**breaking**] Generate random visitorData, remove `RustyPipeQuery::get_context` and `YTContext<'a>` from public API - ([7c4f44d](https://codeberg.org/ThetaDev/rustypipe/commit/7c4f44d09c4d813efff9e7d1059ddacd226b9e9d))
- Add OAuth user login to access age-restricted videos - ([1cc3f9a](https://codeberg.org/ThetaDev/rustypipe/commit/1cc3f9ad74908d33e247ba6243103bfc22540164))
- Add user_auth_logout method - ([9e2fe61](https://codeberg.org/ThetaDev/rustypipe/commit/9e2fe61267846ce216e0c498d8fa9ee672e03cbf))
- Revoke OAuth token when logging out - ([62f8a92](https://codeberg.org/ThetaDev/rustypipe/commit/62f8a9210c23e1f02c711a2294af8766ca6b70e2))
### 🐛 Bug Fixes
- Skip serializing empty cache entries - ([be18d89](https://codeberg.org/ThetaDev/rustypipe/commit/be18d89ea65e35ddcf0f31bea3360e5db209fb9f))
- Fetch artist albums continuation - ([b589061](https://codeberg.org/ThetaDev/rustypipe/commit/b589061a40245637b4fe619a26892291d87d25e6))
- Update channel order tokens - ([79a6281](https://codeberg.org/ThetaDev/rustypipe/commit/79a62816ff62d94e5c706f45b1ce5971e5e58a81))
- Handle auth errors - ([512223f](https://codeberg.org/ThetaDev/rustypipe/commit/512223fd83fb1ba2ba7ad96ed050a70bb7ec294d))
- Use same visitor data for fetching artist album continuations - ([7b0499f](https://codeberg.org/ThetaDev/rustypipe/commit/7b0499f6b7cbf6ac4b83695adadfebb3f30349c7))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate fancy-regex to 0.14.0 (#14) - ([94194e0](https://codeberg.org/ThetaDev/rustypipe/commit/94194e019c46ca49c343086e80e8eb75c52f4bc6))
- *(deps)* Update rust crate quick-xml to 0.37.0 (#15) - ([0662b5c](https://codeberg.org/ThetaDev/rustypipe/commit/0662b5ccfccc922b28629f11ea52c3eb35f9efd2))
## [v0.5.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.4.0..rustypipe/v0.5.0) - 2024-10-13
### 🚀 Features
- Add mobile client - ([71d3ec6](https://codeberg.org/ThetaDev/rustypipe/commit/71d3ec65ddafa966ef6b41cf4eb71687ba4b594c))
### 🐛 Bug Fixes
- Prioritize visitor_data argument before opts - ([ace0fae](https://codeberg.org/ThetaDev/rustypipe/commit/ace0fae1005217cd396000176e7c01682eae026f))
- Ignore live tracks in YTM searches - ([f3f2e1d](https://codeberg.org/ThetaDev/rustypipe/commit/f3f2e1d3ca1e9c838c682356bb5a7ded6951c8e5))
- A/B test 16 (pageHeaderRenderer on playlist pages) - ([e65f145](https://codeberg.org/ThetaDev/rustypipe/commit/e65f14556f3003fa59fee3f9f1410fb5ddf63219))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rstest to 0.23.0 (#12) - ([96776e9](https://codeberg.org/ThetaDev/rustypipe/commit/96776e98d76fa1d31d5f84dbceafbe8f9dfd9085))
## [v0.4.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.3.0..rustypipe/v0.4.0) - 2024-09-10
### 🚀 Features
- Add RustyPipe version constant - ([7a019f5](https://codeberg.org/ThetaDev/rustypipe/commit/7a019f5706e19f7fe9f2e16e3b94d7b98cc8aca9))
### 🐛 Bug Fixes
- Show docs.rs feature flags - ([67a231d](https://codeberg.org/ThetaDev/rustypipe/commit/67a231d6d1b6427f500667729a59032f2b28cc65))
- A/B test 15 (parsing channel shortsLockupViewModel) - ([7972df0](https://codeberg.org/ThetaDev/rustypipe/commit/7972df0df498edd7801e25037b9b2456367f9204))
### 📚 Documentation
- Fix license badge URL - ([4a253e1](https://codeberg.org/ThetaDev/rustypipe/commit/4a253e1a47317e9999e6ad31ac5c411956a0986a))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate tokio to 1.20.4 [security] (#10) - ([a445e51](https://codeberg.org/ThetaDev/rustypipe/commit/a445e51b54a9afc44cd9657260a0b3d2abddbfa6))
## [v0.3.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.2.1..rustypipe/v0.3.0) - 2024-08-18
### 🚀 Features
- Add client_type to VideoPlayer, simplify MapResponse trait - ([90540c6](https://codeberg.org/ThetaDev/rustypipe/commit/90540c6aaad658d4ce24ed41450d8509bac711bd))
- Add http_client method to RustyPipe and user_agent method to RustyPipeQuery - ([3d6de53](https://codeberg.org/ThetaDev/rustypipe/commit/3d6de5354599ea691351e0ca161154e53f2e0b41))
- Add channel_id and channel_name getters to YtEntity trait - ([bbbe9b4](https://codeberg.org/ThetaDev/rustypipe/commit/bbbe9b4b322c6b5b30764772e282c6823aeea524))
- [**breaking**] Make StreamFilter use Vec internally, remove lifetime - ([821984b](https://codeberg.org/ThetaDev/rustypipe/commit/821984bbd51d65cf96b1d14087417ef968eaf9b2))
- Overhauled downloader - ([11a0038](https://codeberg.org/ThetaDev/rustypipe/commit/11a00383502917cd98245c3da349107289ba3aa9))
- Add player_from_clients function to specify client order - ([72b5dfe](https://codeberg.org/ThetaDev/rustypipe/commit/72b5dfec69ec25445b94cb0976662416a5df56ef))
- [**breaking**] Add TV client - ([e608811](https://codeberg.org/ThetaDev/rustypipe/commit/e608811e5f5615416241e67561671330097092cb))
- Downloader: add audio tagging - ([1e1315a](https://codeberg.org/ThetaDev/rustypipe/commit/1e1315a8378bd0ad25b5f1614e83dabc4a0b40d5))
- Add audiotag+indicatif features to downloader - ([97fb057](https://codeberg.org/ThetaDev/rustypipe/commit/97fb0578b5c4954a596d8dee0c4b6e1d773a9300))
- Add YtEntity trait to YouTubeItem and MusicItem - ([114a86a](https://codeberg.org/ThetaDev/rustypipe/commit/114a86a3823a175875aa2aeb31a61a6799ef13bc))
- Change default player client order - ([97904d7](https://codeberg.org/ThetaDev/rustypipe/commit/97904d77374c2c937a49dc7905759c2d8e8ef9ae))
- [**breaking**] Update channel model, addd handle + video_count, remove tv/mobile banner - ([e671570](https://codeberg.org/ThetaDev/rustypipe/commit/e6715700d950912031d5fbc1263f8770b6ffc49c))
- [**breaking**] Add handle to ChannelItem, remove video_count - ([1cffb27](https://codeberg.org/ThetaDev/rustypipe/commit/1cffb27cc0b64929f9627f5839df2d73b81988a4))
- [**breaking**] Remove startpage - ([3599aca](https://codeberg.org/ThetaDev/rustypipe/commit/3599acafef1a21fa6f8dea97902eb4a3fb048c14))
### 🐛 Bug Fixes
- [**breaking**] Extracting nsig function, remove field `throttled` from Video/Audio stream model - ([dd0565b](https://codeberg.org/ThetaDev/rustypipe/commit/dd0565ba98acb3289ed220fd2a3aaf86bb8b0788))
- Make nsig_fn regex more generic - ([fb7af3b](https://codeberg.org/ThetaDev/rustypipe/commit/fb7af3b96698b452b6b24d1e094ba13a245cb83c))
- Improve deobfuscator (support multiple nsig name matches, error if mapping all streams fails) - ([8152ce6](https://codeberg.org/ThetaDev/rustypipe/commit/8152ce6b088b57be9b8419b754aca93805e5f34d))
- Nsig fn extraction - ([3c83e11](https://codeberg.org/ThetaDev/rustypipe/commit/3c83e11e753f8eb6efea5d453a7c819c487b3464))
- Add var to deobf fn assignment - ([c6bd03f](https://codeberg.org/ThetaDev/rustypipe/commit/c6bd03fb70871ae1b764be18f88e86e71818fc56))
- Make Verification enum exhaustive - ([d053ac3](https://codeberg.org/ThetaDev/rustypipe/commit/d053ac3eba810a7241df91f2f50bcbe1fd968c86))
- Extraction error message - ([d36ba59](https://codeberg.org/ThetaDev/rustypipe/commit/d36ba595dab0bbaef1012ebfa8930fc0e6bf8167))
- Set tracing instrumentation level to Error - ([9da3b25](https://codeberg.org/ThetaDev/rustypipe/commit/9da3b25be2b2577f7bd0282c09d10d368ac8b73f))
- Detect ip-ban error message - ([da39c64](https://codeberg.org/ThetaDev/rustypipe/commit/da39c64f302bc2edc4214bbe25a0a9eb54063b09))
- Player_from_clients: fall back to TvHtml5Embed client - ([d0ae796](https://codeberg.org/ThetaDev/rustypipe/commit/d0ae7961ba91d56c8b9a8d1c545875e869b818f5))
- Parsing channels without banner - ([5a6b2c3](https://codeberg.org/ThetaDev/rustypipe/commit/5a6b2c3a621f6b20c1324ea8b9c03426e3d8018b))
- Get TV client version - ([ee3ae40](https://codeberg.org/ThetaDev/rustypipe/commit/ee3ae40395263c5989784c7e00038ff13bc1151a))
### ⚙️ Miscellaneous Tasks
- Renovate: disable approveMajorUpdates - ([4743f9d](https://codeberg.org/ThetaDev/rustypipe/commit/4743f9d8e101b58ad6a43548495da9f4f381b9f4))
- Renovate: disable scheduleDaily - ([015bd6f](https://codeberg.org/ThetaDev/rustypipe/commit/015bd6fcbf04163565fcb190b163ecfdb5664e11))
- Renovate: enable automerge - ([882abc5](https://codeberg.org/ThetaDev/rustypipe/commit/882abc53ca894229ee78ec0edaa723d9ea61bbcb))
- *(deps)* Update rust crate quick-xml to 0.36.0 (#8) - ([b6bc05c](https://codeberg.org/ThetaDev/rustypipe/commit/b6bc05c1f39da9a846b2e3d1d24bcbccb031203b))
- *(deps)* Update rust crate rstest to 0.22.0 (#9) - ([abb7832](https://codeberg.org/ThetaDev/rustypipe/commit/abb783219aba4b492c1dff03c2148acf1f51a55d))
- Change repo URL to Codeberg - ([1793331](https://codeberg.org/ThetaDev/rustypipe/commit/17933315d947f76d5fe1aa52abf7ea24c3ce6381))
- Adjust dependency versions - ([70c6f8c](https://codeberg.org/ThetaDev/rustypipe/commit/70c6f8c3b97baefd316fff90cc727524516657af))
### Todo
- Update metadata - ([8692ca8](https://codeberg.org/ThetaDev/rustypipe/commit/8692ca81d972d0d2acf6fb4da79b9e0f5ebf4daf))
## [v0.2.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.2.0..rustypipe/v0.2.1) - 2024-07-01
### 🐛 Bug Fixes
- *(deps)* Update quick-xml to v0.35.0 - ([298e4de](https://codeberg.org/ThetaDev/rustypipe/commit/298e4def93d1595fba91be103f014aa645a08937))
## [v0.2.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.1.3..rustypipe/v0.2.0) - 2024-06-27
### 🚀 Features
- Add text formatting (bold/italic/strikethrough) - ([b8825f9](https://codeberg.org/ThetaDev/rustypipe/commit/b8825f9199365c873a4f0edd98a435e986b8daa2))
- Prefix chip-style web links (social media) with the service name - ([6c41ef2](https://codeberg.org/ThetaDev/rustypipe/commit/6c41ef2fb2531e10a12c271e2d48504510a3b0bf))
- Make get_visitor_data() public - ([da1d1bd](https://codeberg.org/ThetaDev/rustypipe/commit/da1d1bd2a0b214da10436ae221c90a0f88697b9a))
- Add UnavailabilityReason: IpBan - ([401d4e8](https://codeberg.org/ThetaDev/rustypipe/commit/401d4e8255b1e86444319fed6d114dfbd0f80bbd))
- Add YtEntity trait - ([792e3b3](https://codeberg.org/ThetaDev/rustypipe/commit/792e3b31e0101087a167935baad39a2e3b4296d0))
### 🐛 Bug Fixes
- Remove Innertube API keys, update android player params - ([a8fb337](https://codeberg.org/ThetaDev/rustypipe/commit/a8fb337fae9cb0112e0152f9a0a19ebae49c2a4d))
- Parsing error when no `music_related` content available - ([8fbd6b9](https://codeberg.org/ThetaDev/rustypipe/commit/8fbd6b95b6f01108b46f53fe60a56b0c561e40c1))
- Parsing audiobook type in European Portuguese - ([041ce2d](https://codeberg.org/ThetaDev/rustypipe/commit/041ce2d08f6021c88e8890034f551f7e01b2f012))
- Renovate ci token - ([e0759eb](https://codeberg.org/ThetaDev/rustypipe/commit/e0759ebce32a5520245bb2c0cb920734b04ee7dc))
### 🚜 Refactor
- [**breaking**] Rename VideoItem/VideoPlayerDetails.length to duration for consistency - ([94e8d24](https://codeberg.org/ThetaDev/rustypipe/commit/94e8d24c6848b8bfca70dd03a7d89547ba9d6051))
### 📚 Documentation
- Add logo - ([6646078](https://codeberg.org/ThetaDev/rustypipe/commit/66460789449be0d5984cbdb6ec372e69323b7a88))
### ⚙️ Miscellaneous Tasks
- Changelog: fix incorrect version URLs - ([97b6f07](https://codeberg.org/ThetaDev/rustypipe/commit/97b6f07399e80e00a6c015d013e744568be125dd))
- Update rstest to v0.19.0 - ([50fd1f0](https://codeberg.org/ThetaDev/rustypipe/commit/50fd1f08caf39c1298654e06059cc393543e925b))
- Introduce MSRV - ([5dbb288](https://codeberg.org/ThetaDev/rustypipe/commit/5dbb288a496d53a299effa2026f5258af7b1f176))
- Fix clippy lints - ([45b9f2a](https://codeberg.org/ThetaDev/rustypipe/commit/45b9f2a627b4e7075ba0b1c5f16efcc19aef7922))
- Vscode: enable rss feature by default - ([e75ffbb](https://codeberg.org/ThetaDev/rustypipe/commit/e75ffbb5da6198086385ea96383ab9d0791592a5))
- Configure Renovate (#3) - ([44c2deb](https://codeberg.org/ThetaDev/rustypipe/commit/44c2debea61f70c24ad6d827987e85e2132ed3d1))
- *(deps)* Update rust crate tokio to 1.20.4 [security] (#4) - ([ce3ec34](https://codeberg.org/ThetaDev/rustypipe/commit/ce3ec34337b8acac41410ea39264aab7423d5801))
- *(deps)* Update rust crate quick-xml to 0.34.0 (#5) - ([1e8a1af](https://codeberg.org/ThetaDev/rustypipe/commit/1e8a1af08c873cee7feadf63c2eff62753a78f64))
- *(deps)* Update rust crate rstest to 0.21.0 (#7) - ([c3af918](https://codeberg.org/ThetaDev/rustypipe/commit/c3af918ba53c6230c0e4aef822a0cb2cf120bf3f))
## [v0.1.3](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.1.2..rustypipe/v0.1.3) - 2024-04-01
### 🐛 Bug Fixes
- Parse new comment model (A/B#14 frameworkUpdates) - ([b0331f7](https://codeberg.org/ThetaDev/rustypipe/commit/b0331f7250f5d7d61a45209150739d2cb08b4280))
### ◀️ Revert
- "fix: improve VecLogErr messages" (leads to infinite loop) - ([348c852](https://codeberg.org/ThetaDev/rustypipe/commit/348c8523fe847f2f6ce98317375a7ab65e778ed2))
## [v0.1.2](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.1.1..rustypipe/v0.1.2) - 2024-03-26
### 🐛 Bug Fixes
- Correctly parse subscriber count with new channel header - ([180dd98](https://codeberg.org/ThetaDev/rustypipe/commit/180dd9891a14b4da9f130a73d73aecc3822fce2f))
## [v0.1.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.1.0..rustypipe/v0.1.1) - 2024-03-26
### 🐛 Bug Fixes
- Specify internal dependency versions - ([6598a23](https://codeberg.org/ThetaDev/rustypipe/commit/6598a23d0699e6fe298275a67e0146a19c422c88))
- Move package attributes to workspace - ([e4b204e](https://codeberg.org/ThetaDev/rustypipe/commit/e4b204eae65f450471be0890b0198d2f30714b3b))
- Parsing music details with video description tab - ([a81c3e8](https://codeberg.org/ThetaDev/rustypipe/commit/a81c3e83366fdf72d01dd3ee00fb2e831f7aaa26))
### ⚙️ Miscellaneous Tasks
- Changes to release command - ([0bcced1](https://codeberg.org/ThetaDev/rustypipe/commit/0bcced1db377198a54c9c7d03b8d038125a2bfe4))
- Update user agent (FF 115.0) - ([be314d5](https://codeberg.org/ThetaDev/rustypipe/commit/be314d57ea1d99bfdc80649351ee3e7845541238))
- Fix release script (unquoted include paths) - ([78ba9cb](https://codeberg.org/ThetaDev/rustypipe/commit/78ba9cb34c6bba3aba177583b242d3f76ea9847d))
## [v0.1.0](https://codeberg.org/ThetaDev/rustypipe/commits/tag/rustypipe/v0.1.0) - 2024-03-22
Initial release
<!-- generated by git-cliff -->

View file

@ -1,90 +1,22 @@
[package] [package]
name = "rustypipe" name = "rustypipe"
version = "0.10.0" version = "0.1.0"
rust-version = "1.67.1"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
keywords.workspace = true
categories.workspace = true
description = "Client for the public YouTube / YouTube Music API (Innertube), inspired by NewPipe"
include = ["/src", "README.md", "CHANGELOG.md", "LICENSE", "!snapshots"]
[workspace]
members = [".", "codegen", "downloader", "cli"]
[workspace.package]
edition = "2021" edition = "2021"
authors = ["ThetaDev <thetadev@magenta.de>"] authors = ["ThetaDev <t.testboy@gmail.com>"]
license = "GPL-3.0" license = "GPL-3.0"
repository = "https://codeberg.org/ThetaDev/rustypipe" description = "Client for the public YouTube / YouTube Music API (Innertube), inspired by NewPipe"
keywords = ["youtube", "video", "music"] keywords = ["youtube", "video", "music"]
categories = ["api-bindings", "multimedia"] categories = ["api-bindings", "multimedia"]
[workspace.dependencies] include = ["/src", "README.md", "LICENSE", "!snapshots"]
rquickjs = "0.9.0"
once_cell = "1.12.0"
regex = "1.6.0"
fancy-regex = "0.14.0"
thiserror = "2.0.0"
url = "2.2.0"
reqwest = { version = "0.12.0", default-features = false }
tokio = "1.20.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.82"
serde_with = { version = "3.0.0", default-features = false, features = [
"alloc",
"macros",
] }
serde_plain = "1.0.0"
sha1 = "0.10.0"
rand = "0.8.0"
time = { version = "0.3.37", features = [
"macros",
"serde-human-readable",
"serde-well-known",
"local-offset",
] }
futures-util = "0.3.31"
ress = "0.11.0"
phf = "0.11.0"
phf_codegen = "0.11.0"
data-encoding = "2.0.0"
urlencoding = "2.1.0"
quick-xml = { version = "0.37.0", features = ["serialize"] }
tracing = { version = "0.1.0", features = ["log"] }
localzone = "0.3.1"
# CLI [workspace]
indicatif = "0.17.0" members = [".", "codegen", "downloader", "postprocessor", "cli"]
anyhow = "1.0"
clap = { version = "4.0.0", features = ["derive"] }
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
serde_yaml = "0.9.0"
dirs = "6.0.0"
filenamify = "0.1.0"
# Testing
rstest = "0.24.0"
tokio-test = "0.4.2"
insta = { version = "1.17.1", features = ["ron", "redactions"] }
path_macro = "1.0.0"
tracing-test = "0.2.5"
# Included crates
rustypipe = { path = ".", version = "0.10.0", default-features = false }
rustypipe-downloader = { path = "./downloader", version = "0.3.0", default-features = false, features = [
"indicatif",
"audiotag",
] }
[features] [features]
default = ["default-tls"] default = ["default-tls"]
rss = ["dep:quick-xml"] rss = ["quick-xml"]
userdata = []
# Reqwest TLS options # Reqwest TLS options
default-tls = ["reqwest/default-tls"] default-tls = ["reqwest/default-tls"]
@ -95,38 +27,45 @@ rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
[dependencies] [dependencies]
rquickjs.workspace = true quick-js-dtp = { version = "0.4.1", default-features = false, features = [
once_cell.workspace = true "patch-dateparser",
regex.workspace = true ] }
fancy-regex.workspace = true once_cell = "1.12.0"
thiserror.workspace = true regex = "1.6.0"
url.workspace = true fancy-regex = "0.11.0"
reqwest = { workspace = true, features = ["json", "gzip", "brotli"] } thiserror = "1.0.36"
tokio = { workspace = true, features = ["macros", "time", "process"] } url = "2.2.2"
serde.workspace = true log = "0.4.17"
serde_json.workspace = true reqwest = { version = "0.11.11", default-features = false, features = [
serde_with.workspace = true "json",
serde_plain.workspace = true "gzip",
sha1.workspace = true "brotli",
rand.workspace = true ] }
time.workspace = true tokio = { version = "1.20.0", features = ["macros", "time"] }
ress.workspace = true serde = { version = "1.0", features = ["derive"] }
phf.workspace = true serde_json = "1.0.82"
data-encoding.workspace = true serde_with = { version = "3.0.0", default-features = false, features = [
urlencoding.workspace = true "macros",
tracing.workspace = true "json",
localzone.workspace = true ] }
quick-xml = { workspace = true, optional = true } serde_plain = "1.0.1"
rand = "0.8.5"
time = { version = "0.3.15", features = [
"macros",
"serde",
"serde-well-known",
] }
futures = "0.3.21"
ress = "0.11.4"
phf = "0.11.1"
base64 = "0.21.0"
urlencoding = "2.1.2"
quick-xml = { version = "0.28.1", features = ["serialize"], optional = true }
[dev-dependencies] [dev-dependencies]
rstest.workspace = true env_logger = "0.10.0"
tokio-test.workspace = true test-log = "0.2.11"
insta.workspace = true rstest = "0.17.0"
path_macro.workspace = true tokio-test = "0.4.2"
tracing-test.workspace = true insta = { version = "1.17.1", features = ["ron", "redactions"] }
path_macro = "1.0.0"
[package.metadata.docs.rs]
# To build locally:
# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features rss,userdata --no-deps --open
features = ["rss", "userdata"]
rustdoc-args = ["--cfg", "docsrs"]

View file

@ -1,26 +0,0 @@
## Development
**Requirements:**
- Current version of stable Rust
- [`just`](https://github.com/casey/just) task runner
- [`nextest`](https://nexte.st) test runner
- [`pre-commit`](https://pre-commit.com/)
- yq (YAML processor)
### Tasks
**Testing**
- `just test` Run unit+integration tests
- `just unittest` Run unit tests
- `just testyt` Run YouTube integration tests
- `just testintl` Run YouTube integration tests for all supported languages (this takes
a long time and is therefore not run in CI)
- `YT_LANG=de just testyt` Run YouTube integration tests for a specific language
**Tools**
- `just testfiles` Download missing testfiles for unit tests
- `just report2yaml` Convert RustyPipe reports into a more readable yaml format
(requires `yq`)

View file

@ -1,19 +1,19 @@
test: test:
# cargo test --features=rss,userdata cargo test --features=rss
cargo nextest run --workspace --features=rss,userdata --no-fail-fast --retries 1 -- --skip 'user_data::'
unittest: unittest:
cargo nextest run --features=rss,userdata --no-fail-fast --lib cargo test --features=rss --lib
testyt: testyt:
cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube -- --skip 'user_data::' cargo test --features=rss --test youtube
testyt-cookie: testyt10:
cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube #!/usr/bin/env bash
set -e
testyt-localized: for i in {1..10}; do \
YT_LANG=th cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube -- \ echo "---TEST RUN $i---"; \
--skip 'user_data::' --skip 'search_suggestion' --skip 'isrc_search_languages' cargo test --features=rss --test youtube; \
done
testintl: testintl:
#!/usr/bin/env bash #!/usr/bin/env bash
@ -32,8 +32,7 @@ testintl:
for YT_LANG in "${LANGUAGES[@]}"; do for YT_LANG in "${LANGUAGES[@]}"; do
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 test --test youtube -- --test-threads 4 --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 ---"
@ -48,45 +47,4 @@ testfiles:
report2yaml: report2yaml:
mkdir -p rustypipe_reports/conv mkdir -p rustypipe_reports/conv
for f in rustypipe_reports/*.json; do yq '.http_request.resp_body' $f | yq -o json -P > rustypipe_reports/conv/`basename $f .json`_body.json; yq e -Pi "del(.http_request.resp_body)" $f; mv $f rustypipe_reports/conv/`basename $f .json`.yaml; done; for f in rustypipe_reports/*.json; do yq '.http_request.resp_body' $f | yq -o json -P > rustypipe_reports/conv/`basename $f .json`_body.json; yq e -Pi $f; mv $f rustypipe_reports/conv/`basename $f .json`.yaml; done;
release crate="rustypipe":
#!/usr/bin/env bash
set -e
CRATE="{{crate}}"
CHANGELOG="CHANGELOG.md"
if [ "$CRATE" = "rustypipe" ]; then
INCLUDES="--exclude-path 'notes/**' --exclude-path 'cli/**' --exclude-path 'downloader/**'"
else
if [ ! -d "$CRATE" ]; then
echo "$CRATE does not exist."; exit 1
fi
INCLUDES="--include-path README.md --include-path LICENSE --include-path Cargo.toml --include-path '$CRATE/**'"
CHANGELOG="$CRATE/$CHANGELOG"
CRATE="rustypipe-$CRATE" # Add crate name prefix
fi
VERSION=$(cargo pkgid --package "$CRATE" | tr '#@' '\n' | tail -n 1)
TAG="${CRATE}/v${VERSION}"
echo "Releasing $TAG:"
if git rev-parse "$TAG" >/dev/null 2>&1; then echo "version tag $TAG already exists"; exit 1; fi
CLIFF_ARGS="--tag '${TAG}' --tag-pattern '${CRATE}/v*' --unreleased $INCLUDES"
echo "git-cliff $CLIFF_ARGS"
if [ -f "$CHANGELOG" ]; then
eval "git-cliff $CLIFF_ARGS --prepend '$CHANGELOG'"
else
eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'"
fi
editor "$CHANGELOG"
git add .
git commit -m "chore(release): release $CRATE v$VERSION"
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG"
echo "🚀 Run 'git push origin $TAG' to publish"

139
README.md
View file

@ -1,12 +1,9 @@
# ![RustyPipe](https://codeberg.org/ThetaDev/rustypipe/raw/branch/main/notes/logo.svg) # RustyPipe
[![Current crates.io version](https://img.shields.io/crates/v/rustypipe.svg)](https://crates.io/crates/rustypipe) [![CI status](https://ci.thetadev.de/api/badges/ThetaDev/rustypipe/status.svg)](https://ci.thetadev.de/ThetaDev/rustypipe)
[![License](https://img.shields.io/badge/License-GPL--3-blue.svg?style=flat)](https://opensource.org/licenses/GPL-3.0)
[![Docs](https://img.shields.io/docsrs/rustypipe/latest?style=flat)](https://docs.rs/rustypipe)
[![CI status](https://codeberg.org/ThetaDev/rustypipe/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/rustypipe/actions/?workflow=ci.yaml)
RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music API Client for the public YouTube / YouTube Music API (Innertube), inspired by
(Innertube), inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor). [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
## Features ## Features
@ -21,8 +18,6 @@ RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music
- **Search suggestions** - **Search suggestions**
- **Trending** - **Trending**
- **URL resolver** - **URL resolver**
- **Subscriptions**
- **Playback history**
### YouTube Music ### YouTube Music
@ -36,35 +31,14 @@ RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music
- **Moods/Genres** - **Moods/Genres**
- **Charts** - **Charts**
- **New** (albums, music videos) - **New** (albums, music videos)
- **Saved items**
- **Playback history**
## Getting started ## Getting started
The RustyPipe library works as follows: at first you have to instantiate a RustyPipe
client. You can either create it with default options or use the `RustyPipe::builder()`
to customize it.
For fetching data you have to start with a new RustyPipe query object (`rp.query()`).
The query object holds options for an individual query (e.g. content language or
country). You can adjust these options with setter methods. Finally call your query
method to fetch the data you need.
All query methods are async, you need the tokio runtime to execute them.
```rust ignore
let rp = RustyPipe::new();
let rp = RustyPipe::builder().storage_dir("/app/data").build().unwrap();
let channel = rp.query().lang(Language::De).channel_videos("UCl2mFZoRqjw_ELax4Yisf6w").await.unwrap();
```
Here are a few examples to get you started:
### Cargo.toml ### Cargo.toml
```toml ```toml
[dependencies] [dependencies]
rustypipe = "0.1.3" rustypipe = "0.1.0"
tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
``` ```
@ -180,106 +154,3 @@ Subscribers: 1780000
[6Fv8bd9ICb4] Who owns this? (199s) [6Fv8bd9ICb4] Who owns this? (199s)
... ...
``` ```
## Crate features
Some features of RustyPipe are gated behind features to avoid compiling unneeded
dependencies.
- `rss` Fetch a channel's RSS feed, which is faster than fetching the channel page
- `userdata` Add functions to fetch YouTube user data (watch history, subscriptions,
music library)
You can also choose the TLS library used for making web requests using the same features
as the reqwest crate (`default-tls`, `native-tls`, `native-tls-alpn`,
`native-tls-vendored`, `rustls-tls-webpki-roots`, `rustls-tls-native-roots`).
## Cache storage
The RustyPipe cache holds the current version numbers for all clients, the JavaScript
code used to deobfuscate video URLs and the authentication token/cookies. Never share
the contents of the cache if you are using authentication.
By default the cache is written to a JSON file named `rustypipe_cache.json` in the
current working directory. This path can be changed with the `storage_dir` option of the
RustyPipeBuilder. The RustyPipe CLI stores its cache in the userdata folder. The full
path on Linux is `~/.local/share/rustypipe/rustypipe_cache.json`.
You can integrate your own cache storage backend (e.g. database storage) by implementing
the `CacheStorage` trait.
## Reports
RustyPipe has a builtin error reporting system. If a YouTube response cannot be
deserialized or parsed, the original response data along with some request metadata is
written to a JSON file in the folder `rustypipe_reports`, located in RustyPipe's storage
directory (current folder by default, `~/.local/share/rustypipe` for the CLI).
When submitting a bug report to the RustyPipe project, you can share this report to help
resolve the issue.
RustyPipe reports come in 3 severity levels:
- DBG (no error occurred, report creation was enabled by the `RustyPipeQuery::report`
query option)
- WRN (parts of the response could not be deserialized/parsed, response data may be
incomplete)
- ERR (entire response could not be deserialized/parsed, RustyPipe returned an error)
## PO tokens
Since August 2024 YouTube requires PO tokens to access streams from web-based clients
(Desktop, Mobile). Otherwise streams will return a 403 error.
Generating PO tokens requires a simulated browser environment, which would be too large
to include in RustyPipe directly.
Therefore, the PO token generation is handled by a seperate CLI application
([rustypipe-botguard](https://codeberg.org/ThetaDev/rustypipe-botguard)) which is called
by the RustyPipe crate. RustyPipe automatically detects the rustypipe-botguard binary if
it is located in PATH or the current working directory. If your rustypipe-botguard
binary is located at a different path, you can specify it with the `.botguard_bin(path)`
option.
## Authentication
RustyPipe supports authenticating with your YouTube account to access
age-restricted/private videos and user information. There are 2 supported authentication
methods: OAuth and cookies.
To execute a query with authentication, use the `.authenticated()` query option. This
option is enabled by default for queries that always require authentication like
fetching user data. RustyPipe may automatically use authentication in case a video is
age-restricted or your IP address is banned by YouTube. If you never want to use
authentication, set the `.unauthenticated()` query option.
### OAuth
OAuth is the authentication method used by the YouTube TV client. It is more
user-friendly than extracting cookies, however it only works with the TV client. This
means that you can only fetch videos and not access any user data.
To login using OAuth, you first have to get a new device code using the
`rp.user_auth_get_code()` function. You can then enter the code on
<https://google.com/device> and log in with your Google account. After generating the
code, you can call the `rp.user_auth_wait_for_login()` function which waits until the
user has logged in and stores the authentication token in the cache.
### Cookies
Authenticating with cookies allows you to use the functionality of the YouTube/YouTube
Music Desktop client. You can fetch your subscribed channels, playlists and your music
collection. You can also fetch videos using the Desktop client, including private
videos, as long as you have access to them.
To authenticate with cookies you have to log into YouTube in a fresh browser session
(open Incognito/Private mode). Then extract the cookies from the developer tools or by
using browser plugins like "Get cookies.txt LOCALLY"
([Firefox](https://addons.mozilla.org/de/firefox/addon/get-cookies-txt-locally/))
([Chromium](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)).
Close the browser window after extracting the cookies to prevent YouTube from rotating
the cookies.
You can then add the cookies to your RustyPipe client using the `user_auth_set_cookie`
or `user_auth_set_cookie_txt` function. The cookies are stored in the cache file. To log
out, use the function `user_auth_remove_cookie`.

View file

@ -1,191 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v0.7.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.6.0..rustypipe-cli/v0.7.0) - 2025-02-09
### 🚀 Features
- Add support for rustypipe-botguard to get PO tokens - ([b90a252](https://codeberg.org/ThetaDev/rustypipe/commit/b90a252a5e1bf05a5294168b0ec16a73cbb88f42))
- [**breaking**] Remove manual PO token options from downloader/cli, add new rustypipe-botguard options - ([cddb32f](https://codeberg.org/ThetaDev/rustypipe/commit/cddb32f190276265258c6ab45b3d43a8891c4b39))
- Add session po token cache - ([b72b501](https://codeberg.org/ThetaDev/rustypipe/commit/b72b501b6dbcf4333b24cd80e7c8c61b0c21ec91))
- Add timezone query option - ([3a2370b](https://codeberg.org/ThetaDev/rustypipe/commit/3a2370b97ca3d0f40d72d66a23295557317d29fb))
- Add --timezone-local CLI option - ([4f2bb47](https://codeberg.org/ThetaDev/rustypipe/commit/4f2bb47ab42ae0c68a64f3b3c2831fa7850b6f56))
- Add verbose flag - ([629b590](https://codeberg.org/ThetaDev/rustypipe/commit/629b5905da653c6fe0f3c6b5814dd2f49030e7ed))
### 🐛 Bug Fixes
- Parsing mixed-case language codes like zh-CN - ([9c73ed4](https://codeberg.org/ThetaDev/rustypipe/commit/9c73ed4b3008cb093c0fa7fd94fd9f1ba8cd3627))
### 🚜 Refactor
- [**breaking**] Add client_type field to DownloadError, rename cli option po-token-cache to pot-cache - ([594e675](https://codeberg.org/ThetaDev/rustypipe/commit/594e675b39efc5fbcdbd5e920a4d2cdee64f718e))
- Rename rustypipe-cli binary to rustypipe - ([c1a872e](https://codeberg.org/ThetaDev/rustypipe/commit/c1a872e1c14ea0956053bd7c65f6875b1cb3bc55))
### 📚 Documentation
- Add Botguard info to README - ([9957add](https://codeberg.org/ThetaDev/rustypipe/commit/9957add2b5d6391b2c1869d2019fd7dd91b8cd41))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rustypipe to 0.10.0
- *(deps)* Update rust crate rquickjs to 0.9.0 (#33) - ([2c8ac41](https://codeberg.org/ThetaDev/rustypipe/commit/2c8ac410aa535d83f8bcc7181f81914b13bceb77))
## [v0.6.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.5.0..rustypipe-cli/v0.6.0) - 2025-01-16
### 🚀 Features
- Add functions to fetch a user's history and subscriptions - ([14e3995](https://codeberg.org/ThetaDev/rustypipe/commit/14e399594f97a1228a8c2991a14dd8745af1beb7))
- Add history item dates, extend timeago parser - ([320a8c2](https://codeberg.org/ThetaDev/rustypipe/commit/320a8c2c24217ad5697f0424c4f994bbbe31f3aa))
- Add cookies.txt parser, add cookie auth + history cmds to CLI - ([cf498e4](https://codeberg.org/ThetaDev/rustypipe/commit/cf498e4a8f9318b0197bc3f0cbaf7043c53adb9d))
- Add CLI commands to fetch user library and YTM releases/charts - ([a1b43ad](https://codeberg.org/ThetaDev/rustypipe/commit/a1b43ad70a66cfcbaba8ef302ac8699f243e56e7))
- Export subscriptions as OPML / NewPipe JSON - ([c90d966](https://codeberg.org/ThetaDev/rustypipe/commit/c90d966b17eab24e957d980695888a459707055c))
### 📚 Documentation
- Update README - ([0432477](https://codeberg.org/ThetaDev/rustypipe/commit/0432477451ecd5f64145d65239c721f4e44826c0))
- Fix README - ([11442df](https://codeberg.org/ThetaDev/rustypipe/commit/11442dfd369599396357f5b7a7a4268a7b537f57))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rstest to 0.24.0 (#20) - ([ab19034](https://codeberg.org/ThetaDev/rustypipe/commit/ab19034ab19baf090e83eada056559676ffdadce))
- *(deps)* Update rust crate dirs to v6 (#24) - ([6a60425](https://codeberg.org/ThetaDev/rustypipe/commit/6a604252b1af7a9388db5dc170f737069cc31051))
## [v0.5.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.4.0..rustypipe-cli/v0.5.0) - 2024-12-20
### 🚀 Features
- Get comment replies, rich text formatting - ([dceba44](https://codeberg.org/ThetaDev/rustypipe/commit/dceba442fe1a1d5d8d2a6d9422ff699593131f6d))
### 🐛 Bug Fixes
- Replace futures dependency with futures-util - ([5c39bf4](https://codeberg.org/ThetaDev/rustypipe/commit/5c39bf4842b13d37a4277ea5506e15c179892ce5))
- Error 400 when fetching player with login - ([5ce84c4](https://codeberg.org/ThetaDev/rustypipe/commit/5ce84c44a6844f692258066c83e04df875e0aa91))
### ⚙️ Miscellaneous Tasks
- Add docs badge to README - ([706e881](https://codeberg.org/ThetaDev/rustypipe/commit/706e88134c0e94ce7d880735e9d31b3ff531a4f9))
- *(deps)* Update rustypipe to 0.8.0
## [v0.4.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.3.0..rustypipe-cli/v0.4.0) - 2024-11-10
### 🚀 Features
- Allow searching for YTM users - ([50010b7](https://codeberg.org/ThetaDev/rustypipe/commit/50010b7b0856d3ce05fe7a9d5989e526089bc2ef))
- [**breaking**] Replace `TrackItem::is_video` attr with TrackType enum; serde lowercase AlbumType enum for consistency - ([044094a](https://codeberg.org/ThetaDev/rustypipe/commit/044094a4b70f05c46a459fa1597e23f4224b7b0b))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate thiserror to v2 (#16) - ([e1e1687](https://codeberg.org/ThetaDev/rustypipe/commit/e1e1687605603686ac5fd5deeb6aa8fecaf92494))
## [v0.3.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.2.2..rustypipe-cli/v0.3.0) - 2024-10-28
### 🚀 Features
- [**breaking**] Remove TvHtml5Embed client as it got disabled - ([9e835c8](https://codeberg.org/ThetaDev/rustypipe/commit/9e835c8f38a3dd28c65561b2f9bb7a0f530c24f1))
- Add OAuth user login to access age-restricted videos - ([1cc3f9a](https://codeberg.org/ThetaDev/rustypipe/commit/1cc3f9ad74908d33e247ba6243103bfc22540164))
- Revoke OAuth token when logging out - ([62f8a92](https://codeberg.org/ThetaDev/rustypipe/commit/62f8a9210c23e1f02c711a2294af8766ca6b70e2))
## [v0.2.2](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.2.1..rustypipe-cli/v0.2.2) - 2024-10-13
### 🚀 Features
- Add mobile client - ([71d3ec6](https://codeberg.org/ThetaDev/rustypipe/commit/71d3ec65ddafa966ef6b41cf4eb71687ba4b594c))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rstest to 0.23.0 (#12) - ([96776e9](https://codeberg.org/ThetaDev/rustypipe/commit/96776e98d76fa1d31d5f84dbceafbe8f9dfd9085))
- *(deps)* Update rustypipe to 0.5.0
## [v0.2.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.2.0..rustypipe-cli/v0.2.1) - 2024-09-10
### 🚀 Features
- Add RustyPipe version constant - ([7a019f5](https://codeberg.org/ThetaDev/rustypipe/commit/7a019f5706e19f7fe9f2e16e3b94d7b98cc8aca9))
### 📚 Documentation
- Fix license badge URL - ([4a253e1](https://codeberg.org/ThetaDev/rustypipe/commit/4a253e1a47317e9999e6ad31ac5c411956a0986a))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate tokio to 1.20.4 [security] (#10) - ([a445e51](https://codeberg.org/ThetaDev/rustypipe/commit/a445e51b54a9afc44cd9657260a0b3d2abddbfa6))
## [v0.2.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.1.1..rustypipe-cli/v0.2.0) - 2024-08-18
### 🚀 Features
- Overhauled downloader - ([11a0038](https://codeberg.org/ThetaDev/rustypipe/commit/11a00383502917cd98245c3da349107289ba3aa9))
- [**breaking**] Add TV client - ([e608811](https://codeberg.org/ThetaDev/rustypipe/commit/e608811e5f5615416241e67561671330097092cb))
- Downloader: add audio tagging - ([1e1315a](https://codeberg.org/ThetaDev/rustypipe/commit/1e1315a8378bd0ad25b5f1614e83dabc4a0b40d5))
- Downloader: add download_track fn, improve path templates - ([e1e4fb2](https://codeberg.org/ThetaDev/rustypipe/commit/e1e4fb29c190fec07f17c59ec88bef4f1c2a76a1))
- Add audiotag+indicatif features to downloader - ([97fb057](https://codeberg.org/ThetaDev/rustypipe/commit/97fb0578b5c4954a596d8dee0c4b6e1d773a9300))
- Add plaintext output to CLI - ([91b020e](https://codeberg.org/ThetaDev/rustypipe/commit/91b020efd498eff6e0f354a1de39439e252a79dd))
- Add potoken option to downloader - ([904f821](https://codeberg.org/ThetaDev/rustypipe/commit/904f8215d84c810b04e4d2134718e786a4803ad2))
- Print error message - ([8f16e5b](https://codeberg.org/ThetaDev/rustypipe/commit/8f16e5ba6eec3fd6aba1bb6a19571c65fb69ce0e))
- Add list of clients to downloader - ([5e646af](https://codeberg.org/ThetaDev/rustypipe/commit/5e646afd1edc6c0101501311527ea56d3bad5fd2))
- Retry with different client after 403 error - ([d875b54](https://codeberg.org/ThetaDev/rustypipe/commit/d875b5442de9822ba7ddc6f05789f56a8962808c))
- Add option to fetch RSS feed - ([03c4d3c](https://codeberg.org/ThetaDev/rustypipe/commit/03c4d3c392386e06f2673f0e0783e22d10087989))
- [**breaking**] Update channel model, addd handle + video_count, remove tv/mobile banner - ([e671570](https://codeberg.org/ThetaDev/rustypipe/commit/e6715700d950912031d5fbc1263f8770b6ffc49c))
### 🐛 Bug Fixes
- *(deps)* Update quick-xml to v0.35.0 - ([298e4de](https://codeberg.org/ThetaDev/rustypipe/commit/298e4def93d1595fba91be103f014aa645a08937))
- Improve deobfuscator (support multiple nsig name matches, error if mapping all streams fails) - ([8152ce6](https://codeberg.org/ThetaDev/rustypipe/commit/8152ce6b088b57be9b8419b754aca93805e5f34d))
- Cli: print video ID when logging errors - ([2c7a3fb](https://codeberg.org/ThetaDev/rustypipe/commit/2c7a3fb5cc153ff0b8b5e79234ae497d916e471c))
- Use anstream + owo-color for colorful CLI output - ([e8324cf](https://codeberg.org/ThetaDev/rustypipe/commit/e8324cf3b065cb977adbc9529b1ef5ee18c3dd47))
- Use native tls by default for CLI - ([f37432a](https://codeberg.org/ThetaDev/rustypipe/commit/f37432a48c1f93cab5f7942f791daf7b27cb1565))
- Detect ip-ban error message - ([da39c64](https://codeberg.org/ThetaDev/rustypipe/commit/da39c64f302bc2edc4214bbe25a0a9eb54063b09))
- Dont store cache in current dir with --report option - ([6009de7](https://codeberg.org/ThetaDev/rustypipe/commit/6009de7bddc6031f2af17005c473c17934327c02))
- Show docs.rs feature flags - ([67a231d](https://codeberg.org/ThetaDev/rustypipe/commit/67a231d6d1b6427f500667729a59032f2b28cc65))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate quick-xml to 0.36.0 (#8) - ([b6bc05c](https://codeberg.org/ThetaDev/rustypipe/commit/b6bc05c1f39da9a846b2e3d1d24bcbccb031203b))
- *(deps)* Update rust crate rstest to 0.22.0 (#9) - ([abb7832](https://codeberg.org/ThetaDev/rustypipe/commit/abb783219aba4b492c1dff03c2148acf1f51a55d))
- Change repo URL to Codeberg - ([1793331](https://codeberg.org/ThetaDev/rustypipe/commit/17933315d947f76d5fe1aa52abf7ea24c3ce6381))
- Adjust dependency versions - ([70c6f8c](https://codeberg.org/ThetaDev/rustypipe/commit/70c6f8c3b97baefd316fff90cc727524516657af))
### Todo
- Update metadata - ([8692ca8](https://codeberg.org/ThetaDev/rustypipe/commit/8692ca81d972d0d2acf6fb4da79b9e0f5ebf4daf))
## [v0.1.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.1.0..rustypipe-cli/v0.1.1) - 2024-06-27
### 🚀 Features
- CLI: setting player type - ([16e0e28](https://codeberg.org/ThetaDev/rustypipe/commit/16e0e28c4866bb69d8e4c06eef94176f329a1c27))
### 🐛 Bug Fixes
- Clippy warning - ([8420c2f](https://codeberg.org/ThetaDev/rustypipe/commit/8420c2f8dbd2791b524ceca2e19fb68e5b918bfa))
### 📚 Documentation
- Add logo - ([6646078](https://codeberg.org/ThetaDev/rustypipe/commit/66460789449be0d5984cbdb6ec372e69323b7a88))
### ⚙️ Miscellaneous Tasks
- Changelog: fix incorrect version URLs - ([97b6f07](https://codeberg.org/ThetaDev/rustypipe/commit/97b6f07399e80e00a6c015d013e744568be125dd))
- Update rstest to v0.19.0 - ([50fd1f0](https://codeberg.org/ThetaDev/rustypipe/commit/50fd1f08caf39c1298654e06059cc393543e925b))
- Introduce MSRV - ([5dbb288](https://codeberg.org/ThetaDev/rustypipe/commit/5dbb288a496d53a299effa2026f5258af7b1f176))
- Fix clippy lints - ([45b9f2a](https://codeberg.org/ThetaDev/rustypipe/commit/45b9f2a627b4e7075ba0b1c5f16efcc19aef7922))
- *(deps)* Update rust crate tokio to 1.20.4 [security] (#4) - ([ce3ec34](https://codeberg.org/ThetaDev/rustypipe/commit/ce3ec34337b8acac41410ea39264aab7423d5801))
- *(deps)* Update rust crate quick-xml to 0.34.0 (#5) - ([1e8a1af](https://codeberg.org/ThetaDev/rustypipe/commit/1e8a1af08c873cee7feadf63c2eff62753a78f64))
- *(deps)* Update rust crate rstest to 0.21.0 (#7) - ([c3af918](https://codeberg.org/ThetaDev/rustypipe/commit/c3af918ba53c6230c0e4aef822a0cb2cf120bf3f))
- Update rustypipe to 0.2.0
## [v0.1.0](https://codeberg.org/ThetaDev/rustypipe/commits/tag/rustypipe-cli/v0.1.0) - 2024-03-22
Initial release
<!-- generated by git-cliff -->

View file

@ -1,18 +1,15 @@
[package] [package]
name = "rustypipe-cli" name = "rustypipe-cli"
version = "0.7.0" version = "0.1.0"
rust-version = "1.70.0" edition = "2021"
edition.workspace = true authors = ["ThetaDev <t.testboy@gmail.com>"]
authors.workspace = true license = "GPL-3.0"
license.workspace = true
repository.workspace = true
keywords.workspace = true
categories.workspace = true
description = "CLI for RustyPipe - download videos and extract data from YouTube / YouTube Music" description = "CLI for RustyPipe - download videos and extract data from YouTube / YouTube Music"
keywords = ["youtube", "video", "music"]
categories = ["multimedia"]
[features] [features]
default = ["native-tls"] default = ["rustls-tls-native-roots"]
timezone = ["dep:time", "dep:time-tz"]
# Reqwest TLS options # Reqwest TLS options
native-tls = [ native-tls = [
@ -42,29 +39,16 @@ rustls-tls-native-roots = [
] ]
[dependencies] [dependencies]
rustypipe = { workspace = true, features = ["rss", "userdata"] } rustypipe = { path = "../", default-features = false }
rustypipe-downloader.workspace = true rustypipe-downloader = { path = "../downloader", default-features = false }
reqwest.workspace = true reqwest = { version = "0.11.11", default_features = false }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
futures-util.workspace = true indicatif = "0.17.0"
serde.workspace = true futures = "0.3.21"
serde_json.workspace = true anyhow = "1.0"
quick-xml.workspace = true clap = { version = "4.0.29", features = ["derive"] }
time = { workspace = true, optional = true } env_logger = "0.10.0"
time-tz = { version = "2.0.0", optional = true } serde = "1.0"
serde_json = "1.0.82"
indicatif.workspace = true serde_yaml = "0.9.19"
anyhow.workspace = true dirs = "5.0.0"
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde_yaml.workspace = true
dirs.workspace = true
anstream = "0.6.15"
owo-colors = "4.0.0"
const_format = "0.2.33"
[[bin]]
name = "rustypipe"
path = "src/main.rs"

View file

@ -1,174 +0,0 @@
# ![RustyPipe](https://codeberg.org/ThetaDev/rustypipe/raw/branch/main/notes/logo.svg) CLI
[![Current crates.io version](https://img.shields.io/crates/v/rustypipe-cli.svg)](https://crates.io/crates/rustypipe-cli)
[![License](https://img.shields.io/badge/License-GPL--3-blue.svg?style=flat)](https://opensource.org/licenses/GPL-3.0)
[![CI status](https://codeberg.org/ThetaDev/rustypipe/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/rustypipe/actions/?workflow=ci.yaml)
The RustyPipe CLI is a powerful YouTube client for the command line. It allows you to
access most of the features of the RustyPipe crate: getting data from YouTube and
downloading videos.
## 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
You can call the get command with any YouTube entity ID or URL and RustyPipe will fetch
the associated metadata. It can fetch channels, playlists, albums and videos.
**Usage:** `rustypipe get UC2TXq_t06Hjdr2g_KdKpHQg`
- `-l`, `--limit` Limit the number of list items to fetch
- `-t`, `--tab` Channel tab (options: **videos**, shorts, live, playlists, info)
- `-m, --music` Use the YouTube Music API
- `--rss`Fetch the RSS feed of a channel
- `--comments` Get comments (options: top, latest)
- `--lyrics` Get the lyrics for YTM tracks
- `--player` Get the player data instead of the video details when fetching videos
- `-c`, `--client-type` YT clients used to fetch player data (options: desktop, tv,
tv-embed, android, ios; if multiple clients are specified, they are attempted in
order)
## `search`: Search YouTube
With the search command you can search the entire YouTube platform or individual
channels. YouTube Music search is also supported.
Note that search filters are only supported when searching YouTube. They have no effect
when searching YTM or individual channels.
**Usage:** `rustypipe search "query"`
### Options
- `-l`, `--limit` Limit the number of list items to fetch
- `--item-type` Filter results by item type
- `--length` Filter results by video length
- `--date` Filter results by upload date (options: hour, day, week, month, year)
- `--order` Sort search results (options: rating, date, views)
- `--channel` Channel ID for searching channel videos
- `-m`, `--music` Search YouTube Music in the given category (options: all, tracks,
videos, artists, albums, playlists-ytm, playlists-community)
## `dl`: Download videos
The downloader can download individual videos, playlists, albums and channels. Multiple
videos can be downloaded in parallel for improved performance.
**Usage:** `rustypipe dl eRsGyueVLvQ`
### Options
- `-o`, `--output` Download to the given directory
- `--output-file` Download to the given file
- `--template` Download to a path determined by a template
- `-r`, `--resolution` Video resolution (e.g. 720, 1080). Set to 0 for audio-only
- `-a`, `--audio` Download only the audio track and write track metadata + album cover
- `-p`, `--parallel` Number of videos downloaded in parallel (default: 8)
- `-m`, `--music` Use YouTube Music for downloading playlists
- `-l`, `--limit` Limit the number of videos to download (default: 1000)
- `-c`, `--client-type` YT clients used to fetch player data (options: desktop, tv,
tv-embed, android, ios; if multiple clients are specified, they are attempted in
order)
## `vdata`: Get visitor data
You can use the vdata command to get a new visitor data ID. This feature may come in
handy for testing and reproducing A/B tests.
## `releases` Get YouTube Music new releases
Get a list of new albums or music videos on YouTube Music
**Usage:** `rustypipe releases` or `rustypipe releases --videos`
## `charts`: Get YouTube Music charts
Get a list of the most popular tracks and artists for a given country
**Usage:** `rustypipe charts DE`
## `history`: Get YouTube playback history
Get a list of recently played videos or tracks
### Options
- `-l`, `--limit` Limit the number of list items to fetch
- `--search` Search the playback history (unavailable on YouTube Music)
- `-m`, `--music` Get the YouTube Music playback history
## `subscriptions`: Get subscribed channels
You can use the RustyPipe CLI to get a list of the channels you subscribed to. With the
`--format` flag you can export then in different formats, including OPML and NewPipe
JSON.
With the `--feed` option you can output a list of the latest videos from your
subscription feed instead.
### Options
- `-l`, `--limit` Limit the number of list items to fetch
- `-m`, `--music` Get a list of subscribed YouTube Music artists
- `--feed` Output YouTube Music subscription feed
## `playlists`, `albums`, `tracks`: Get your YouTube library
Fetch a list of all the items saved in your YouTube/YouTube Music profile.
### Options
- `-l`, `--limit` Limit the number of list items to fetch
- `-m`, `--music` (only for playlists): Get your YouTube Music playlists
## Global options
- **Proxy:** RustyPipe respects the environment variables `HTTP_PROXY`, `HTTPS_PROXY`
and `ALL_PROXY`
- **Logging:** Enable debug logging with the `-v` (verbose) flag. If you want more
fine-grained control, use the `RUST_LOG` environment variable.
- **Visitor data:** A custom visitor data ID can be used with the `--vdata` flag
- **Authentication:** Use the commands `rustypipe login` and `rustypipe login --cookie`
to log into your Google account using either OAuth or YouTube cookies. With the
`--auth` flag you can use authentication for any request.
- `--lang` Change the YouTube content language
- `--country` Change the YouTube content country
- `--tz` Use a specific
[timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g.
Europe/Berlin, Australia/Sydney)
**Note:** this requires building rustypipe-cli with the `timezone` feature
- `--local-tz` Use the local timezone instead of UTC
- `--report` Generate a report on every request and store it in a `rustypipe_reports`
folder in the current directory
- `--cache-file` Change the RustyPipe cache file location (Default:
`~/.local/share/rustypipe/rustypipe_cache.json`)
- `--report-dir` Change the RustyPipe report directory location (Default:
`~/.local/share/rustypipe/rustypipe_reports`)
- `--botguard-bin` Use a
[rustypipe-botguard](https://codeberg.org/ThetaDev/rustypipe-botguard) binary from the
given path for generating PO tokens
- `--no-botguard` Disable Botguard, only download videos using clients that dont require
it
- `--pot-cache` Enable caching for session-bound PO tokens
### Output format
By default, the CLI outputs YouTube data in a human-readable text format. If you want to
store the data or process it with a script, you should choose a machine readable output
format. You can choose both JSON and YAML with the `-f, --format` flag.

File diff suppressed because it is too large Load diff

View file

@ -1,100 +0,0 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% set repo_url = "https://codeberg.org/ThetaDev/rustypipe" %}\
{% if version %}\
{%set vname = version | split(pat="/") | last %}
{%if previous.version %}\
## [{{ vname }}]({{ repo_url }}/compare/{{ previous.version }}..{{ version }})\
{% else %}\
## [{{ vname }}]({{ repo_url }}/commits/tag/{{ version }})\
{% endif %} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% if previous.version %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }} - \
([{{ commit.id | truncate(length=7, end="") }}]({{ repo_url }}/commit/{{ commit.id }}))\
{% endfor %}
{% endfor %}\
{% else %}
Initial release
{% endif %}\n
"""
# template for the changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# remove the leading and trailing s
trim = true
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", skip = true },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ message = "^ci", skip = true },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# regex for matching git tags
# tag_pattern = "v[0-9].*"
# regex for skipping tags
# skip_tags = ""
# regex for ignoring tags
# ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
# limit the number of commits included in the changelog.
# limit_commits = 42

View file

@ -1,33 +1,26 @@
[package] [package]
name = "rustypipe-codegen" name = "rustypipe-codegen"
version = "0.1.0" version = "0.1.0"
rust-version = "1.74.0" edition = "2021"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
publish = false publish = false
[dependencies] [dependencies]
rustypipe = { path = "../", features = ["userdata"] } rustypipe = { path = "../" }
reqwest.workspace = true reqwest = "0.11.11"
tokio = { workspace = true, features = ["rt-multi-thread"] } tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
futures-util.workspace = true futures = "0.3.21"
serde.workspace = true serde = { version = "1.0", features = ["derive"] }
serde_json.workspace = true serde_json = "1.0.82"
serde_plain.workspace = true serde_with = { version = "3.0.0", default-features = false, features = ["macros"] }
serde_with.workspace = true anyhow = "1.0"
once_cell.workspace = true log = "0.4.17"
regex.workspace = true env_logger = "0.10.0"
path_macro.workspace = true clap = { version = "4.0.29", features = ["derive"] }
anyhow.workspace = true phf_codegen = "0.11.1"
tracing.workspace = true once_cell = "1.12.0"
tracing-subscriber.workspace = true regex = "1.7.1"
clap.workspace = true indicatif = "0.17.0"
phf_codegen.workspace = true num_enum = "0.6.1"
indicatif.workspace = true path_macro = "1.0.0"
num_enum = "0.7.2"
intl_pluralrules = "7.0.2" intl_pluralrules = "7.0.2"
unic-langid = "0.9.1" unic-langid = "0.9.1"
ordered_hash_map = { version = "0.4.0", features = ["serde"] }

View file

@ -1,20 +1,15 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use futures_util::{stream, StreamExt}; use futures::{stream, StreamExt};
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use num_enum::TryFromPrimitive; use num_enum::TryFromPrimitive;
use once_cell::sync::Lazy; use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery, YTContext};
use regex::Regex; use rustypipe::model::YouTubeItem;
use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery};
use rustypipe::model::{MusicItem, YouTubeItem};
use rustypipe::param::search_filter::{ItemType, SearchFilter}; use rustypipe::param::search_filter::{ItemType, SearchFilter};
use rustypipe::param::ChannelVideoTab;
use serde::de::IgnoredAny; use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::model::QCont;
#[derive( #[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, TryFromPrimitive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, TryFromPrimitive, Serialize, Deserialize,
)] )]
@ -26,26 +21,12 @@ pub enum ABTest {
TrendsVideoTab = 4, TrendsVideoTab = 4,
TrendsPageHeaderRenderer = 5, TrendsPageHeaderRenderer = 5,
DiscographyPage = 6, DiscographyPage = 6,
ShortDateFormat = 7,
TrackViewcount = 8,
PlaylistsForShorts = 9,
ChannelAboutModal = 10,
LikeButtonViewmodel = 11,
ChannelPageHeader = 12,
MusicPlaylistTwoColumn = 13,
CommentsFrameworkUpdate = 14,
ChannelShortsLockup = 15,
PlaylistPageHeader = 16,
ChannelPlaylistsLockup = 17,
MusicPlaylistFacepile = 18,
MusicAlbumGroupsReordered = 19,
MusicContinuationItemRenderer = 20,
} }
/// List of active A/B tests that are run when none is manually specified const TESTS_TO_RUN: [ABTest; 3] = [
const TESTS_TO_RUN: &[ABTest] = &[ ABTest::TrendsVideoTab,
ABTest::MusicAlbumGroupsReordered, ABTest::TrendsPageHeaderRenderer,
ABTest::MusicContinuationItemRenderer, ABTest::DiscographyPage,
]; ];
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -60,6 +41,7 @@ pub struct ABTestRes {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct QVideo<'a> { struct QVideo<'a> {
context: YTContext<'a>,
video_id: &'a str, video_id: &'a str,
content_check_ok: bool, content_check_ok: bool,
racy_check_ok: bool, racy_check_ok: bool,
@ -68,6 +50,7 @@ struct QVideo<'a> {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QBrowse<'a> { struct QBrowse<'a> {
context: YTContext<'a>,
browse_id: &'a str, browse_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
params: Option<&'a str>, params: Option<&'a str>,
@ -82,6 +65,7 @@ pub async fn run_test(
let rp = RustyPipe::new(); let rp = RustyPipe::new();
let pb = ProgressBar::new(n as u64); let pb = ProgressBar::new(n as u64);
let http = reqwest::Client::default();
pb.set_style( pb.set_style(
ProgressStyle::with_template( ProgressStyle::with_template(
"{msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}", "{msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}",
@ -93,8 +77,9 @@ pub async fn run_test(
.map(|_| { .map(|_| {
let rp = rp.clone(); let rp = rp.clone();
let pb = pb.clone(); let pb = pb.clone();
let http = http.clone();
async move { async move {
let visitor_data = rp.query().get_visitor_data(true).await.unwrap(); let visitor_data = get_visitor_data(&http).await;
let query = rp.query().visitor_data(&visitor_data); let query = rp.query().visitor_data(&visitor_data);
let is_present = match ab { let is_present = match ab {
ABTest::AttributedTextDescription => attributed_text_description(&query).await, ABTest::AttributedTextDescription => attributed_text_description(&query).await,
@ -105,22 +90,6 @@ pub async fn run_test(
ABTest::TrendsVideoTab => trends_video_tab(&query).await, ABTest::TrendsVideoTab => trends_video_tab(&query).await,
ABTest::TrendsPageHeaderRenderer => trends_page_header_renderer(&query).await, ABTest::TrendsPageHeaderRenderer => trends_page_header_renderer(&query).await,
ABTest::DiscographyPage => discography_page(&query).await, ABTest::DiscographyPage => discography_page(&query).await,
ABTest::ShortDateFormat => short_date_format(&query).await,
ABTest::PlaylistsForShorts => playlists_for_shorts(&query).await,
ABTest::TrackViewcount => track_viewcount(&query).await,
ABTest::ChannelAboutModal => channel_about_modal(&query).await,
ABTest::LikeButtonViewmodel => like_button_viewmodel(&query).await,
ABTest::ChannelPageHeader => channel_page_header(&query).await,
ABTest::MusicPlaylistTwoColumn => music_playlist_two_column(&query).await,
ABTest::CommentsFrameworkUpdate => comments_framework_update(&query).await,
ABTest::ChannelShortsLockup => channel_shorts_lockup(&query).await,
ABTest::PlaylistPageHeader => playlist_page_header_renderer(&query).await,
ABTest::ChannelPlaylistsLockup => channel_playlists_lockup(&query).await,
ABTest::MusicPlaylistFacepile => music_playlist_facepile(&query).await,
ABTest::MusicAlbumGroupsReordered => music_album_groups_reordered(&query).await,
ABTest::MusicContinuationItemRenderer => {
music_continuation_item_renderer(&query).await
}
} }
.unwrap(); .unwrap();
pb.inc(1); pb.inc(1);
@ -142,14 +111,30 @@ pub async fn run_test(
(count, vd_present, vd_absent) (count, vd_present, vd_absent)
} }
async fn get_visitor_data(http: &reqwest::Client) -> String {
let resp = http.get("https://www.youtube.com").send().await.unwrap();
resp.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.find_map(|c| {
if let Ok(cookie) = c.to_str() {
if let Some(after) = cookie.strip_prefix("__Secure-YEC=") {
return after.split_once(';').map(|s| s.0.to_owned());
}
}
None
})
.unwrap()
}
pub async fn run_all_tests(n: usize, concurrency: usize) -> Vec<ABTestRes> { pub async fn run_all_tests(n: usize, concurrency: usize) -> Vec<ABTestRes> {
let mut results = Vec::new(); let mut results = Vec::new();
for ab in TESTS_TO_RUN { for ab in TESTS_TO_RUN {
let (occurrences, vd_present, vd_absent) = run_test(*ab, n, concurrency).await; let (occurrences, vd_present, vd_absent) = run_test(ab, n, concurrency).await;
results.push(ABTestRes { results.push(ABTestRes {
id: *ab as u16, id: ab as u16,
name: *ab, name: ab,
tests: n, tests: n,
occurrences, occurrences,
vd_present, vd_present,
@ -160,12 +145,14 @@ pub async fn run_all_tests(n: usize, concurrency: usize) -> Vec<ABTestRes> {
} }
pub async fn attributed_text_description(rp: &RustyPipeQuery) -> Result<bool> { pub async fn attributed_text_description(rp: &RustyPipeQuery) -> Result<bool> {
let context = rp.get_context(ClientType::Desktop, true, None).await;
let q = QVideo { let q = QVideo {
context,
video_id: "ZeerrnuLi5E", video_id: "ZeerrnuLi5E",
content_check_ok: false, content_check_ok: false,
racy_check_ok: false, racy_check_ok: false,
}; };
let response_txt = rp.raw(ClientType::Desktop, "next", &q).await?; let response_txt = rp.raw(ClientType::Desktop, "next", &q).await.unwrap();
if !response_txt.contains("\"Black Mamba\"") { if !response_txt.contains("\"Black Mamba\"") {
bail!("invalid response data"); bail!("invalid response data");
@ -175,7 +162,7 @@ pub async fn attributed_text_description(rp: &RustyPipeQuery) -> Result<bool> {
} }
pub async fn three_tab_channel_layout(rp: &RustyPipeQuery) -> Result<bool> { pub async fn three_tab_channel_layout(rp: &RustyPipeQuery) -> Result<bool> {
let channel = rp.channel_videos("UCR-DXc1voovS8nhAvccRZhg").await?; let channel = rp.channel_videos("UCR-DXc1voovS8nhAvccRZhg").await.unwrap();
Ok(channel.has_live || channel.has_shorts) Ok(channel.has_live || channel.has_shorts)
} }
@ -188,18 +175,20 @@ pub async fn channel_handles_in_search_results(rp: &RustyPipeQuery) -> Result<bo
Ok(search.items.items.iter().any(|itm| match itm { Ok(search.items.items.iter().any(|itm| match itm {
YouTubeItem::Channel(channel) => channel YouTubeItem::Channel(channel) => channel
.subscriber_count .subscriber_count
.map(|sc| sc > 100 && channel.handle.is_some()) .map(|sc| sc > 100 && channel.video_count.is_none())
.unwrap_or_default(), .unwrap_or_default(),
_ => false, _ => false,
})) }))
} }
pub async fn trends_video_tab(rp: &RustyPipeQuery) -> Result<bool> { pub async fn trends_video_tab(rp: &RustyPipeQuery) -> Result<bool> {
let context = rp.get_context(ClientType::Desktop, true, None).await;
let res = rp let res = rp
.raw( .raw(
ClientType::Desktop, ClientType::Desktop,
"browse", "browse",
&QBrowse { &QBrowse {
context,
browse_id: "FEtrending", browse_id: "FEtrending",
params: None, params: None,
}, },
@ -210,11 +199,13 @@ pub async fn trends_video_tab(rp: &RustyPipeQuery) -> Result<bool> {
} }
pub async fn trends_page_header_renderer(rp: &RustyPipeQuery) -> Result<bool> { pub async fn trends_page_header_renderer(rp: &RustyPipeQuery) -> Result<bool> {
let context = rp.get_context(ClientType::Desktop, true, None).await;
let res = rp let res = rp
.raw( .raw(
ClientType::Desktop, ClientType::Desktop,
"browse", "browse",
&QBrowse { &QBrowse {
context,
browse_id: "FEtrending", browse_id: "FEtrending",
params: None, params: None,
}, },
@ -232,214 +223,10 @@ pub async fn trends_page_header_renderer(rp: &RustyPipeQuery) -> Result<bool> {
} }
pub async fn discography_page(rp: &RustyPipeQuery) -> Result<bool> { pub async fn discography_page(rp: &RustyPipeQuery) -> Result<bool> {
let id = "UC7cl4MmM6ZZ2TcFyMk_b4pg"; let artist = rp
let res = rp .music_artist("UC7cl4MmM6ZZ2TcFyMk_b4pg", false)
.raw( .await
ClientType::DesktopMusic, .unwrap();
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(res.contains(&format!("\"MPAD{id}\"")))
}
pub async fn short_date_format(rp: &RustyPipeQuery) -> Result<bool> { Ok(artist.albums.len() <= 10)
static SHORT_DATE: Lazy<Regex> = Lazy::new(|| Regex::new("\\d(?:y|mo|w|d|h|min) ").unwrap());
let channel = rp.channel_videos("UC2DjFE7Xf11URZqWBigcVOQ").await?;
Ok(channel.content.items.iter().any(|itm| {
itm.publish_date_txt
.as_deref()
.map(|d| SHORT_DATE.is_match(d))
.unwrap_or_default()
}))
}
pub async fn playlists_for_shorts(rp: &RustyPipeQuery) -> Result<bool> {
let playlist = rp.playlist("UUSHh8gHdtzO2tXd593_bjErWg").await?;
let v1 = playlist
.videos
.items
.first()
.ok_or_else(|| anyhow::anyhow!("no videos"))?;
Ok(v1.publish_date_txt.is_none())
}
pub async fn track_viewcount(rp: &RustyPipeQuery) -> Result<bool> {
let res = rp.music_search_main("lieblingsmensch namika").await?;
let track = &res
.items
.items
.iter()
.find_map(|itm| {
if let MusicItem::Track(track) = itm {
if track.id == "6485PhOtHzY" {
Some(track)
} else {
None
}
} else {
None
}
})
.unwrap_or_else(|| {
panic!("could not find track, got {:#?}", &res.items.items);
});
Ok(track.view_count.is_some())
}
pub async fn channel_about_modal(rp: &RustyPipeQuery) -> Result<bool> {
let id = "UC2DjFE7Xf11URZqWBigcVOQ";
let res = rp
.raw(
ClientType::Desktop,
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(!res.contains("\"EgVhYm91dPIGBAoCEgA%3D\""))
}
pub async fn like_button_viewmodel(rp: &RustyPipeQuery) -> Result<bool> {
let res = rp
.raw(
ClientType::Desktop,
"next",
&QVideo {
video_id: "ZeerrnuLi5E",
content_check_ok: true,
racy_check_ok: true,
},
)
.await?;
Ok(res.contains("\"segmentedLikeDislikeButtonViewModel\""))
}
pub async fn channel_page_header(rp: &RustyPipeQuery) -> Result<bool> {
let channel = rp
.channel_videos_tab("UCh8gHdtzO2tXd593_bjErWg", ChannelVideoTab::Shorts)
.await?;
Ok(channel.video_count.is_some())
}
pub async fn music_playlist_two_column(rp: &RustyPipeQuery) -> Result<bool> {
let id = "VLRDCLAK5uy_kb7EBi6y3GrtJri4_ZH56Ms786DFEimbM";
let res = rp
.raw(
ClientType::DesktopMusic,
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(res.contains("\"musicResponsiveHeaderRenderer\""))
}
pub async fn comments_framework_update(rp: &RustyPipeQuery) -> Result<bool> {
let continuation =
"Eg0SC3dMZHBSN2d1S3k4GAYyJSIRIgt3TGRwUjdndUt5ODAAeAJCEGNvbW1lbnRzLXNlY3Rpb24%3D";
let res = rp
.raw(ClientType::Desktop, "next", &QCont { continuation })
.await?;
Ok(res.contains("\"frameworkUpdates\""))
}
pub async fn channel_shorts_lockup(rp: &RustyPipeQuery) -> Result<bool> {
let id = "UCh8gHdtzO2tXd593_bjErWg";
let res = rp
.raw(
ClientType::Desktop,
"browse",
&QBrowse {
browse_id: id,
params: Some("EgZzaG9ydHPyBgUKA5oBAA%3D%3D"),
},
)
.await?;
Ok(res.contains("\"shortsLockupViewModel\""))
}
pub async fn playlist_page_header_renderer(rp: &RustyPipeQuery) -> Result<bool> {
let id = "VLPLZN_exA7d4RVmCQrG5VlWIjMOkMFZVVOc";
let res = rp
.raw(
ClientType::Desktop,
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(res.contains("\"pageHeaderRenderer\""))
}
pub async fn channel_playlists_lockup(rp: &RustyPipeQuery) -> Result<bool> {
let id = "UC2DjFE7Xf11URZqWBigcVOQ";
let res = rp
.raw(
ClientType::Desktop,
"browse",
&QBrowse {
browse_id: id,
params: Some("EglwbGF5bGlzdHMgAQ%3D%3D"),
},
)
.await?;
Ok(res.contains("\"lockupViewModel\""))
}
pub async fn music_playlist_facepile(rp: &RustyPipeQuery) -> Result<bool> {
let id = "VLPL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe";
let res = rp
.raw(
ClientType::DesktopMusic,
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(res.contains("\"facepile\""))
}
pub async fn music_album_groups_reordered(rp: &RustyPipeQuery) -> Result<bool> {
let id = "UCOR4_bSVIXPsGa4BbCSt60Q";
let res = rp
.raw(
ClientType::DesktopMusic,
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(res.contains("\"Singles & EPs\""))
}
pub async fn music_continuation_item_renderer(rp: &RustyPipeQuery) -> Result<bool> {
let id = "VLPLbZIPy20-1pN7mqjckepWF78ndb6ci_qi";
let res = rp
.raw(
ClientType::DesktopMusic,
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(res.contains("\"continuationItemRenderer\""))
} }

View file

@ -1,41 +1,28 @@
use std::{collections::BTreeMap, fs::File, io::BufReader}; use std::{collections::BTreeMap, fs::File, io::BufReader};
use futures_util::stream::{self, StreamExt}; use futures::stream::{self, StreamExt};
use path_macro::path; use path_macro::path;
use rustypipe::{ use rustypipe::{
client::{ClientType, RustyPipe, RustyPipeQuery}, client::{ClientType, RustyPipe, RustyPipeQuery},
model::AlbumType, model::AlbumType,
param::{Language, LANGUAGES}, param::{Language, LANGUAGES},
}; };
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use serde_with::rust::deserialize_ignore_any;
use crate::{ use crate::{
model::{ContentsRenderer, QBrowse, SectionList, Tab, TextRuns}, model::{QBrowse, TextRuns},
util::{self, DICT_DIR}, util::{self, DICT_DIR},
}; };
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
enum AlbumTypeX {
Album,
Ep,
Single,
Audiobook,
Show,
AlbumRow,
SingleRow,
}
pub async fn collect_album_types(concurrency: usize) { pub async fn collect_album_types(concurrency: usize) {
let json_path = path!(*DICT_DIR / "album_type_samples.json"); let json_path = path!(*DICT_DIR / "album_type_samples.json");
let album_types = [ let album_types = [
(AlbumTypeX::Album, "MPREb_nlBWQROfvjo"), (AlbumType::Album, "MPREb_nlBWQROfvjo"),
(AlbumTypeX::Single, "MPREb_bHfHGoy7vuv"), (AlbumType::Single, "MPREb_bHfHGoy7vuv"),
(AlbumTypeX::Ep, "MPREb_u1I69lSAe5v"), (AlbumType::Ep, "MPREb_u1I69lSAe5v"),
(AlbumTypeX::Audiobook, "MPREb_gaoNzsQHedo"), (AlbumType::Audiobook, "MPREb_gaoNzsQHedo"),
(AlbumTypeX::Show, "MPREb_cwzk8EUwypZ"), (AlbumType::Show, "MPREb_cwzk8EUwypZ"),
]; ];
let rp = RustyPipe::new(); let rp = RustyPipe::new();
@ -45,7 +32,7 @@ pub async fn collect_album_types(concurrency: usize) {
let rp = rp.clone(); let rp = rp.clone();
async move { async move {
let query = rp.query().lang(lang); let query = rp.query().lang(lang);
let mut data: BTreeMap<AlbumTypeX, String> = BTreeMap::new(); let mut data: BTreeMap<AlbumType, String> = BTreeMap::new();
for (album_type, id) in album_types { for (album_type, id) in album_types {
let atype_txt = get_album_type(&query, id).await; let atype_txt = get_album_type(&query, id).await;
@ -53,22 +40,6 @@ pub async fn collect_album_types(concurrency: usize) {
data.insert(album_type, atype_txt); data.insert(album_type, atype_txt);
} }
let (albums_txt, singles_txt) = get_album_groups(&query).await;
println!(
"collected {}-{:?} ({})",
lang,
AlbumTypeX::AlbumRow,
&albums_txt
);
println!(
"collected {}-{:?} ({})",
lang,
AlbumTypeX::SingleRow,
&singles_txt
);
data.insert(AlbumTypeX::AlbumRow, albums_txt);
data.insert(AlbumTypeX::SingleRow, singles_txt);
(lang, data) (lang, data)
} }
}) })
@ -84,7 +55,7 @@ pub fn write_samples_to_dict() {
let json_path = path!(*DICT_DIR / "album_type_samples.json"); let json_path = path!(*DICT_DIR / "album_type_samples.json");
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let collected: BTreeMap<Language, BTreeMap<String, String>> = let collected: BTreeMap<Language, BTreeMap<AlbumType, String>> =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let mut dict = util::read_dict(); let mut dict = util::read_dict();
let langs = dict.keys().copied().collect::<Vec<_>>(); let langs = dict.keys().copied().collect::<Vec<_>>();
@ -96,12 +67,10 @@ pub fn write_samples_to_dict() {
e_langs.push(lang); e_langs.push(lang);
for lang in &e_langs { for lang in &e_langs {
collected.get(lang).unwrap().iter().for_each(|(t_str, v)| { collected.get(lang).unwrap().iter().for_each(|(t, v)| {
let t =
serde_plain::from_str::<AlbumType>(t_str.split('_').next().unwrap()).unwrap();
dict_entry dict_entry
.album_types .album_types
.insert(v.to_lowercase().trim().to_owned(), t); .insert(v.to_lowercase().trim().to_owned(), *t);
}); });
} }
} }
@ -111,19 +80,13 @@ pub fn write_samples_to_dict() {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct AlbumData { struct AlbumData {
contents: AlbumContents, header: Header,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AlbumContents { struct Header {
two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<AlbumHeader>>>, music_detail_header_renderer: HeaderRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlbumHeader {
music_responsive_header_renderer: HeaderRenderer,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -132,7 +95,11 @@ struct HeaderRenderer {
} }
async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String { async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String {
let context = query
.get_context(ClientType::DesktopMusic, true, None)
.await;
let body = QBrowse { let body = QBrowse {
context,
browse_id: id, browse_id: id,
params: None, params: None,
}; };
@ -143,20 +110,8 @@ async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String {
let album = serde_json::from_str::<AlbumData>(&response_txt).unwrap(); let album = serde_json::from_str::<AlbumData>(&response_txt).unwrap();
album album
.contents .header
.two_column_browse_results_renderer .music_detail_header_renderer
.contents
.into_iter()
.next()
.unwrap()
.tab_renderer
.content
.section_list_renderer
.contents
.into_iter()
.next()
.unwrap()
.music_responsive_header_renderer
.subtitle .subtitle
.runs .runs
.into_iter() .into_iter()
@ -164,84 +119,3 @@ async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String {
.unwrap() .unwrap()
.text .text
} }
async fn get_album_groups(query: &RustyPipeQuery) -> (String, String) {
let body = QBrowse {
browse_id: "UCOR4_bSVIXPsGa4BbCSt60Q",
params: None,
};
let response_txt = query
.clone()
.visitor_data("CgtwbzJZcS1XZWc1QSjM2JG8BjIKCgJERRIEEgAgCw%3D%3D")
.raw(ClientType::DesktopMusic, "browse", &body)
.await
.unwrap();
let artist = serde_json::from_str::<ArtistData>(&response_txt).unwrap();
let sections = artist
.contents
.single_column_browse_results_renderer
.contents
.into_iter()
.next()
.map(|c| c.tab_renderer.content.section_list_renderer.contents)
.unwrap();
let titles = sections
.into_iter()
.filter_map(|s| {
if let ItemSection::MusicCarouselShelfRenderer(r) = s {
r.header
} else {
None
}
})
.map(|h| {
h.music_carousel_shelf_basic_header_renderer
.title
.runs
.into_iter()
.next()
.unwrap()
.text
})
.collect::<Vec<_>>();
assert!(titles.len() >= 2, "too few sections");
let mut titles_it = titles.into_iter();
(titles_it.next().unwrap(), titles_it.next().unwrap())
}
#[derive(Debug, Deserialize)]
struct ArtistData {
contents: ArtistDataContents,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ArtistDataContents {
single_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<ItemSection>>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
enum ItemSection {
MusicCarouselShelfRenderer(MusicCarouselShelf),
#[serde(other, deserialize_with = "deserialize_ignore_any")]
None,
}
#[derive(Debug, Deserialize)]
struct MusicCarouselShelf {
header: Option<MusicCarouselShelfHeader>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MusicCarouselShelfHeader {
music_carousel_shelf_basic_header_renderer: MusicCarouselShelfHeaderRenderer,
}
#[derive(Debug, Deserialize)]
struct MusicCarouselShelfHeaderRenderer {
title: TextRuns,
}

View file

@ -1,75 +0,0 @@
use std::{collections::BTreeMap, fs::File, io::BufReader};
use path_macro::path;
use rustypipe::{
client::RustyPipe,
param::{Language, LANGUAGES},
};
use serde::{Deserialize, Serialize};
use crate::util::{self, DICT_DIR};
#[derive(Debug, Serialize, Deserialize)]
struct Entry {
prefix: String,
suffix: String,
}
pub async fn collect_chan_prefixes() {
let cname = "kiernanchrisman";
let json_path = path!(*DICT_DIR / "chan_prefixes.json");
let mut res = BTreeMap::new();
let rp = RustyPipe::new();
for lang in LANGUAGES {
let playlist = rp
.query()
.lang(lang)
.playlist("PLZN_exA7d4RVmCQrG5VlWIjMOkMFZVVOc")
.await
.unwrap();
let n = playlist.channel.unwrap().name;
let offset = n.find(cname).unwrap();
let prefix = &n[..offset];
let suffix = &n[(offset + cname.len())..];
res.insert(
lang,
Entry {
prefix: prefix.to_owned(),
suffix: suffix.to_owned(),
},
);
}
let file = File::create(json_path).unwrap();
serde_json::to_writer_pretty(file, &res).unwrap();
}
pub fn write_samples_to_dict() {
let json_path = path!(*DICT_DIR / "chan_prefixes.json");
let json_file = File::open(json_path).unwrap();
let collected: BTreeMap<Language, Entry> =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let mut dict = util::read_dict();
let langs = dict.keys().copied().collect::<Vec<_>>();
for lang in langs {
let dict_entry = dict.entry(lang).or_default();
let e = collected.get(&lang).unwrap();
dict_entry.chan_prefix = e.prefix.trim().to_owned();
dict_entry.chan_suffix = e.suffix.trim().to_owned();
for lang in &dict_entry.equivalent {
let ee = collected.get(lang).unwrap();
if ee.prefix != e.prefix || ee.suffix != e.suffix {
panic!("equivalent lang conflict, lang: {lang}");
}
}
}
util::write_dict(dict);
}

View file

@ -1,110 +0,0 @@
use std::{collections::BTreeMap, fs::File, io::BufReader};
use path_macro::path;
use rustypipe::{
client::RustyPipe,
param::{Language, LANGUAGES},
};
use crate::util::{self, DICT_DIR};
type CollectedDates = BTreeMap<Language, BTreeMap<String, String>>;
const THIS_WEEK: &str = "this_week";
const LAST_WEEK: &str = "last_week";
pub async fn collect_dates_music() {
let json_path = path!(*DICT_DIR / "history_date_samples.json");
let rp = RustyPipe::builder()
.storage_dir(path!(env!("CARGO_MANIFEST_DIR") / ".."))
.build()
.unwrap();
let mut res: CollectedDates = {
let json_file = File::open(&json_path).unwrap();
serde_json::from_reader(BufReader::new(json_file)).unwrap()
};
for lang in LANGUAGES {
println!("{lang}");
let history = rp.query().lang(lang).music_history().await.unwrap();
if history.items.len() < 3 {
panic!("{lang} empty history")
}
// The indexes have to be adapted before running
let entry = res.entry(lang).or_default();
entry.insert(
THIS_WEEK.to_owned(),
history.items[0].playback_date_txt.clone().unwrap(),
);
entry.insert(
LAST_WEEK.to_owned(),
history.items[18].playback_date_txt.clone().unwrap(),
);
}
let file = File::create(&json_path).unwrap();
serde_json::to_writer_pretty(file, &res).unwrap();
}
pub async fn collect_dates() {
let json_path = path!(*DICT_DIR / "history_date_samples.json");
let rp = RustyPipe::builder()
.storage_dir(path!(env!("CARGO_MANIFEST_DIR") / ".."))
.build()
.unwrap();
let mut res: CollectedDates = {
let json_file = File::open(&json_path).unwrap();
serde_json::from_reader(BufReader::new(json_file)).unwrap()
};
for lang in LANGUAGES {
println!("{lang}");
let history = rp.query().lang(lang).history().await.unwrap();
if history.items.len() < 3 {
panic!("{lang} empty history")
}
let entry = res.entry(lang).or_default();
entry.insert(
"tuesday".to_owned(),
history.items[0].playback_date_txt.clone().unwrap(),
);
entry.insert(
"0000-01-06".to_owned(),
history.items[1].playback_date_txt.clone().unwrap(),
);
entry.insert(
"2024-12-28".to_owned(),
history.items[15].playback_date_txt.clone().unwrap(),
);
}
let file = File::create(&json_path).unwrap();
serde_json::to_writer_pretty(file, &res).unwrap();
}
pub fn write_samples_to_dict() {
let json_path = path!(*DICT_DIR / "history_date_samples.json");
let json_file = File::open(json_path).unwrap();
let collected_dates: CollectedDates =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let mut dict = util::read_dict();
let langs = dict.keys().copied().collect::<Vec<_>>();
for lang in langs {
let dict_entry = dict.entry(lang).or_default();
let cd = &collected_dates[&lang];
dict_entry
.timeago_nd_tokens
.insert(util::filter_datestr(&cd[THIS_WEEK]), "0Wl".to_owned());
dict_entry
.timeago_nd_tokens
.insert(util::filter_datestr(&cd[LAST_WEEK]), "1Wl".to_owned());
}
util::write_dict(dict);
}

View file

@ -6,7 +6,7 @@ use std::{
}; };
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use futures_util::{stream, StreamExt}; use futures::{stream, StreamExt};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use path_macro::path; use path_macro::path;
use regex::Regex; use regex::Regex;
@ -350,6 +350,7 @@ async fn get_channel(query: &RustyPipeQuery, channel_id: &str) -> Result<Channel
ClientType::Desktop, ClientType::Desktop,
"browse", "browse",
&QBrowse { &QBrowse {
context: query.get_context(ClientType::Desktop, true, None).await,
browse_id: channel_id, browse_id: channel_id,
params: Some("EgZ2aWRlb3MYASAAMAE"), params: Some("EgZ2aWRlb3MYASAAMAE"),
}, },
@ -391,6 +392,7 @@ async fn get_channel(query: &RustyPipeQuery, channel_id: &str) -> Result<Channel
ClientType::Desktop, ClientType::Desktop,
"browse", "browse",
&QCont { &QCont {
context: query.get_context(ClientType::Desktop, true, None).await,
continuation: &popular_token, continuation: &popular_token,
}, },
) )
@ -429,6 +431,9 @@ async fn music_channel_subscribers(query: &RustyPipeQuery, channel_id: &str) ->
ClientType::DesktopMusic, ClientType::DesktopMusic,
"browse", "browse",
&QBrowse { &QBrowse {
context: query
.get_context(ClientType::DesktopMusic, true, None)
.await,
browse_id: channel_id, browse_id: channel_id,
params: None, params: None,
}, },

View file

@ -5,8 +5,7 @@ use std::{
io::BufReader, io::BufReader,
}; };
use futures_util::{stream, StreamExt}; use futures::{stream, StreamExt};
use ordered_hash_map::OrderedHashMap;
use path_macro::path; use path_macro::path;
use rustypipe::{ use rustypipe::{
client::RustyPipe, client::RustyPipe,
@ -66,9 +65,9 @@ pub async fn collect_dates(concurrency: usize) {
// These are the sample playlists // These are the sample playlists
let cases = [ let cases = [
(DateCase::Today, "PL3oW2tjiIxvQ98ZTLhBh5soCbE1mC3uAT"), (DateCase::Today, "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj"),
(DateCase::Yesterday, "PLGBuKfnErZlCkRRgt06em8nbXvcV5Sae7"), (DateCase::Yesterday, "PL3oW2tjiIxvQ98ZTLhBh5soCbE1mC3uAT"),
(DateCase::Ago, "PLAQ7nLSEnhWTEihjeM1I-ToPDJEKfZHZu"), (DateCase::Ago, "PLeDakahyfrO9Amk2GFrzpI4UWOkgqzoIE"),
(DateCase::Jan, "PL1J-6JOckZtFjcni6Xj1pLYglJp6JCpKD"), (DateCase::Jan, "PL1J-6JOckZtFjcni6Xj1pLYglJp6JCpKD"),
(DateCase::Feb, "PL1J-6JOckZtETrbzwZE7mRIIK6BzWNLAs"), (DateCase::Feb, "PL1J-6JOckZtETrbzwZE7mRIIK6BzWNLAs"),
(DateCase::Mar, "PL1J-6JOckZtG3AVdvBXhMO64mB2k3BtKi"), (DateCase::Mar, "PL1J-6JOckZtG3AVdvBXhMO64mB2k3BtKi"),
@ -171,7 +170,7 @@ pub fn write_samples_to_dict() {
dict_entry.months = BTreeMap::new(); dict_entry.months = BTreeMap::new();
if collect_nd_tokens { if collect_nd_tokens {
dict_entry.timeago_nd_tokens = OrderedHashMap::new(); dict_entry.timeago_nd_tokens = BTreeMap::new();
} }
for datestr_table in &datestr_tables { for datestr_table in &datestr_tables {

View file

@ -1,84 +0,0 @@
use std::{
collections::{BTreeMap, HashSet},
fs::File,
};
use futures_util::{stream, StreamExt};
use path_macro::path;
use rustypipe::{
client::{RustyPipe, RustyPipeQuery},
param::{Language, LANGUAGES},
};
use crate::util::DICT_DIR;
pub async fn collect_video_dates(concurrency: usize) {
let json_path = path!(*DICT_DIR / "timeago_samples_short.json");
let rp = RustyPipe::builder()
.visitor_data("Cgtwel9tMkh2eHh0USiyzc6jBg%3D%3D")
.build()
.unwrap();
let channels = [
"UCeY0bbntWzzVIaj2z3QigXg",
"UCcmpeVbSSQlZRvHfdC-CRwg",
"UC65afEgL62PGFWXY7n6CUbA",
"UCEOXxzW2vU0P-0THehuIIeg",
];
let mut lang_strings: BTreeMap<Language, Vec<String>> = BTreeMap::new();
for lang in LANGUAGES {
println!("{lang}");
let query = rp.query().lang(lang);
let strings = stream::iter(channels)
.map(|id| get_channel_datestrings(&query, id))
.buffered(concurrency)
.collect::<Vec<_>>()
.await
.into_iter()
.flatten()
.collect::<Vec<_>>();
lang_strings.insert(lang, strings);
}
let mut en_strings_uniq: HashSet<&str> = HashSet::new();
let mut uniq_ids: HashSet<usize> = HashSet::new();
lang_strings[&Language::En]
.iter()
.enumerate()
.for_each(|(n, s)| {
if en_strings_uniq.insert(s) {
uniq_ids.insert(n);
}
});
let strings_map = lang_strings
.iter()
.map(|(lang, strings)| {
(
lang,
strings
.iter()
.enumerate()
.filter(|(n, _)| uniq_ids.contains(n))
.map(|(_, s)| s)
.collect::<Vec<_>>(),
)
})
.collect::<BTreeMap<_, _>>();
let file = File::create(json_path).unwrap();
serde_json::to_writer_pretty(file, &strings_map).unwrap();
}
async fn get_channel_datestrings(rp: &RustyPipeQuery, id: &str) -> Vec<String> {
let channel = rp.channel_videos(id).await.unwrap();
channel
.content
.items
.into_iter()
.filter_map(|itm| itm.publish_date_txt)
.collect()
}

View file

@ -5,7 +5,7 @@ use std::{
}; };
use anyhow::Result; use anyhow::Result;
use futures_util::{stream, StreamExt}; use futures::{stream, StreamExt};
use path_macro::path; use path_macro::path;
use rustypipe::{ use rustypipe::{
client::{ClientType, RustyPipe, RustyPipeQuery}, client::{ClientType, RustyPipe, RustyPipeQuery},
@ -270,6 +270,7 @@ async fn get_channel_vlengths(
ClientType::Desktop, ClientType::Desktop,
"browse", "browse",
&QBrowse { &QBrowse {
context: query.get_context(ClientType::Desktop, true, None).await,
browse_id: channel_id, browse_id: channel_id,
params: Some("EgZ2aWRlb3MYASAAMAE"), params: Some("EgZ2aWRlb3MYASAAMAE"),
}, },

View file

@ -8,7 +8,6 @@ use std::{
use path_macro::path; use path_macro::path;
use rustypipe::{ use rustypipe::{
client::{ClientType, RustyPipe}, client::{ClientType, RustyPipe},
model::YouTubeItem,
param::{ param::{
search_filter::{self, ItemType, SearchFilter}, search_filter::{self, ItemType, SearchFilter},
ChannelVideoTab, Country, ChannelVideoTab, Country,
@ -38,6 +37,8 @@ pub async fn download_testfiles() {
search_cont().await; search_cont().await;
search_playlists().await; search_playlists().await;
search_empty().await; search_empty().await;
startpage().await;
startpage_cont().await;
trending().await; trending().await;
music_playlist().await; music_playlist().await;
@ -62,23 +63,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_saved_artists().await;
music_saved_albums().await;
music_saved_tracks().await;
music_saved_playlists().await;
} }
const CLIENT_TYPES: [ClientType; 5] = [ const CLIENT_TYPES: [ClientType; 5] = [
ClientType::Desktop, ClientType::Desktop,
ClientType::DesktopMusic, ClientType::DesktopMusic,
ClientType::Tv, ClientType::TvHtml5Embed,
ClientType::Android, ClientType::Android,
ClientType::Ios, ClientType::Ios,
]; ];
@ -144,7 +134,6 @@ fn rp_testfile(json_path: &Path) -> RustyPipe {
.report() .report()
.strict() .strict()
.build() .build()
.unwrap()
} }
async fn player() { async fn player() {
@ -166,7 +155,7 @@ async fn player() {
} }
async fn player_model() { async fn player_model() {
let rp = RustyPipe::builder().strict().build().unwrap(); let rp = RustyPipe::builder().strict().build();
for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] { for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] {
let json_path = let json_path =
@ -402,10 +391,7 @@ async fn search() {
} }
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
rp.query() rp.query().search("doobydoobap").await.unwrap();
.search::<YouTubeItem, _>("doobydoobap")
.await
.unwrap();
} }
async fn search_cont() { async fn search_cont() {
@ -415,11 +401,7 @@ async fn search_cont() {
} }
let rp = RustyPipe::new(); let rp = RustyPipe::new();
let search = rp let search = rp.query().search("doobydoobap").await.unwrap();
.query()
.search::<YouTubeItem, _>("doobydoobap")
.await
.unwrap();
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
search.items.next(rp.query()).await.unwrap().unwrap(); search.items.next(rp.query()).await.unwrap().unwrap();
@ -433,7 +415,7 @@ async fn search_playlists() {
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
rp.query() rp.query()
.search_filter::<YouTubeItem, _>("pop", &SearchFilter::new().item_type(ItemType::Playlist)) .search_filter("pop", &SearchFilter::new().item_type(ItemType::Playlist))
.await .await
.unwrap(); .unwrap();
} }
@ -446,7 +428,7 @@ async fn search_empty() {
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
rp.query() rp.query()
.search_filter::<YouTubeItem, _>( .search_filter(
"test", "test",
&SearchFilter::new() &SearchFilter::new()
.feature(search_filter::Feature::IsLive) .feature(search_filter::Feature::IsLive)
@ -456,6 +438,29 @@ async fn search_empty() {
.unwrap(); .unwrap();
} }
async fn startpage() {
let json_path = path!(*TESTFILES_DIR / "trends" / "startpage.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().startpage().await.unwrap();
}
async fn startpage_cont() {
let json_path = path!(*TESTFILES_DIR / "trends" / "startpage_cont.json");
if json_path.exists() {
return;
}
let rp = RustyPipe::new();
let startpage = rp.query().startpage().await.unwrap();
let rp = rp_testfile(&json_path);
startpage.next(rp.query()).await.unwrap();
}
async fn trending() { async fn trending() {
let json_path = path!(*TESTFILES_DIR / "trends" / "trending_videos.json"); let json_path = path!(*TESTFILES_DIR / "trends" / "trending_videos.json");
if json_path.exists() { if json_path.exists() {
@ -466,36 +471,6 @@ async fn trending() {
rp.query().trending().await.unwrap(); rp.query().trending().await.unwrap();
} }
async fn history() {
let json_path = path!(*TESTFILES_DIR / "userdata" / "history.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().history().await.unwrap();
}
async fn subscriptions() {
let json_path = path!(*TESTFILES_DIR / "userdata" / "subscriptions.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().subscriptions().await.unwrap();
}
async fn subscription_feed() {
let json_path = path!(*TESTFILES_DIR / "userdata" / "subscription_feed.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().subscription_feed().await.unwrap();
}
async fn music_playlist() { async fn music_playlist() {
for (name, id) in [ for (name, id) in [
("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"),
@ -582,7 +557,7 @@ async fn music_search() {
} }
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
rp.query().music_search_main(query).await.unwrap(); rp.query().music_search(query).await.unwrap();
} }
} }
@ -643,7 +618,7 @@ async fn music_search_playlists() {
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
rp.query() rp.query()
.music_search_playlists("pop", community) .music_search_playlists_filter("pop", community)
.await .await
.unwrap(); .unwrap();
} }
@ -817,53 +792,3 @@ async fn music_genre() {
rp.query().music_genre(id).await.unwrap(); rp.query().music_genre(id).await.unwrap();
} }
} }
async fn music_history() {
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "music_history.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_history().await.unwrap();
}
async fn music_saved_artists() {
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_artists.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_artists().await.unwrap();
}
async fn music_saved_albums() {
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_albums.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_albums().await.unwrap();
}
async fn music_saved_tracks() {
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_tracks.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_tracks().await.unwrap();
}
async fn music_saved_playlists() {
let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_playlists.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_playlists().await.unwrap();
}

View file

@ -10,7 +10,7 @@ use crate::{
}; };
fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) { fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) {
static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w*)$").unwrap()); static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w?)$").unwrap());
match TU_PATTERN.captures(tu) { match TU_PATTERN.captures(tu) {
Some(cap) => ( Some(cap) => (
cap.get(1).unwrap().as_str().parse().unwrap_or(1), cap.get(1).unwrap().as_str().parse().unwrap_or(1),
@ -22,8 +22,6 @@ fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) {
"W" => Some(TimeUnit::Week), "W" => Some(TimeUnit::Week),
"M" => Some(TimeUnit::Month), "M" => Some(TimeUnit::Month),
"Y" => Some(TimeUnit::Year), "Y" => Some(TimeUnit::Year),
"Wl" => Some(TimeUnit::LastWeek),
"Wd" => Some(TimeUnit::LastWeekday),
"" => None, "" => None,
_ => panic!("invalid time unit: {tu}"), _ => panic!("invalid time unit: {tu}"),
}, },
@ -45,7 +43,7 @@ pub fn generate_dictionary() {
use crate::{ use crate::{
model::AlbumType, model::AlbumType,
param::Language, param::Language,
util::timeago::{TaToken, TimeUnit}, util::timeago::{DateCmp, TaToken, TimeUnit},
}; };
/// Dictionary entry containing language-specific parsing information /// Dictionary entry containing language-specific parsing information
@ -57,13 +55,14 @@ pub(crate) struct Entry {
/// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay), /// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay),
/// `h`(our), `m`(inute), `s`(econd) /// `h`(our), `m`(inute), `s`(econd)
pub timeago_tokens: phf::Map<&'static str, TaToken>, pub timeago_tokens: phf::Map<&'static str, TaToken>,
/// True if the month has to be parsed before the day /// Order in which to parse numeric date components. Formatted as
/// a string of date identifiers (Y, M, D).
/// ///
/// Examples: /// Examples:
/// ///
/// - 03.01.2020 => DMY => false /// - 03.01.2020 => `"DMY"`
/// - 01/03/2020 => MDY => true /// - Jan 3, 2020 => `"DY"`
pub month_before_day: bool, pub date_order: &'static [DateCmp],
/// Tokens for parsing month names. /// Tokens for parsing month names.
/// ///
/// Format: Parsed token -> Month number (starting from 1) /// Format: Parsed token -> Month number (starting from 1)
@ -86,10 +85,6 @@ pub(crate) struct Entry {
/// ///
/// Format: Parsed text -> Album type /// Format: Parsed text -> Album type
pub album_types: phf::Map<&'static str, AlbumType>, pub album_types: phf::Map<&'static str, AlbumType>,
/// Channel name prefix on playlist pages (e.g. `by`)
pub chan_prefix: &'static str,
/// Channel name suffix on playlist pages
pub chan_suffix: &'static str,
} }
"#; "#;
@ -138,6 +133,13 @@ pub(crate) fn entry(lang: Language) -> Entry {
}; };
}); });
// Date order
let mut date_order = "&[".to_owned();
entry.date_order.chars().for_each(|c| {
write!(date_order, "DateCmp::{c}, ").unwrap();
});
date_order = date_order.trim_end_matches([' ', ',']).to_owned() + "]";
// Number tokens // Number tokens
let mut number_tokens = phf_codegen::Map::<&str>::new(); let mut number_tokens = phf_codegen::Map::<&str>::new();
entry.number_tokens.iter().for_each(|(txt, mag)| { entry.number_tokens.iter().for_each(|(txt, mag)| {
@ -178,8 +180,8 @@ pub(crate) fn entry(lang: Language) -> Entry {
.to_string() .to_string()
.replace('\n', "\n "); .replace('\n', "\n ");
write!(code_timeago_tokens, "{} => Entry {{\n timeago_tokens: {},\n month_before_day: {:?},\n months: {},\n timeago_nd_tokens: {},\n comma_decimal: {:?},\n number_tokens: {},\n number_nd_tokens: {},\n album_types: {},\n chan_prefix: {:?},\n chan_suffix: {:?},\n }},\n ", write!(code_timeago_tokens, "{} => Entry {{\n timeago_tokens: {},\n date_order: {},\n months: {},\n timeago_nd_tokens: {},\n comma_decimal: {:?},\n number_tokens: {},\n number_nd_tokens: {},\n album_types: {},\n }},\n ",
selector, code_ta_tokens, entry.month_before_day, code_months, code_ta_nd_tokens, entry.comma_decimal, code_number_tokens, code_number_nd_tokens, code_album_types, entry.chan_prefix, entry.chan_suffix).unwrap(); selector, code_ta_tokens, date_order, code_months, code_ta_nd_tokens, entry.comma_decimal, code_number_tokens, code_number_nd_tokens, code_album_types).unwrap();
} }
code_timeago_tokens = code_timeago_tokens.trim_end().to_owned() + "\n }\n}\n"; code_timeago_tokens = code_timeago_tokens.trim_end().to_owned() + "\n }\n}\n";

View file

@ -202,20 +202,11 @@ pub enum Country {
.to_owned(); .to_owned();
let mut code_lang_array = format!( let mut code_lang_array = format!(
r#"/// Array of all available languages "/// Array of all available languages\npub const LANGUAGES: [Language; {}] = [\n",
/// The languages are sorted by their native names. This array can be used to display
/// a language selection or to get the language code from a language name using binary search.
pub const LANGUAGES: [Language; {}] = [
"#,
languages.len() languages.len()
); );
let mut code_country_array = format!( let mut code_country_array = format!(
r#"/// Array of all available countries "/// Array of all available countries\npub const COUNTRIES: [Country; {}] = [\n",
///
/// The countries are sorted by their english names. This array can be used to display
/// a country selection or to get the country code from a country name using binary search.
pub const COUNTRIES: [Country; {}] = [
"#,
countries.len() countries.len()
); );
@ -237,15 +228,16 @@ pub const COUNTRIES: [Country; {}] = [
.to_owned(); .to_owned();
for (code, native_name) in &languages { for (code, native_name) in &languages {
let enum_name = code.split('-').fold(String::new(), |mut output, c| { let enum_name = code
let _ = write!( .split('-')
output, .map(|c| {
format!(
"{}{}", "{}{}",
c[0..1].to_owned().to_uppercase(), c[0..1].to_owned().to_uppercase(),
c[1..].to_owned().to_lowercase() c[1..].to_owned().to_lowercase()
); )
output })
}); .collect::<String>();
let en_name = lang_names.get(code).expect(code); let en_name = lang_names.get(code).expect(code);
@ -261,6 +253,9 @@ pub const COUNTRIES: [Country; {}] = [
code_langs += &enum_name; code_langs += &enum_name;
code_langs += ",\n"; code_langs += ",\n";
// Language array
writeln!(code_lang_array, " Language::{enum_name},").unwrap();
// Language names // Language names
writeln!( writeln!(
code_lang_names, code_lang_names,
@ -270,24 +265,6 @@ pub const COUNTRIES: [Country; {}] = [
} }
code_langs += "}\n"; code_langs += "}\n";
// Language array
let languages_by_name = languages
.iter()
.map(|(k, v)| (v, k))
.collect::<BTreeMap<_, _>>();
for code in languages_by_name.values() {
let enum_name = code.split('-').fold(String::new(), |mut output, c| {
let _ = write!(
output,
"{}{}",
c[0..1].to_owned().to_uppercase(),
c[1..].to_owned().to_lowercase()
);
output
});
writeln!(code_lang_array, " Language::{enum_name},").unwrap();
}
for (c, n) in &countries { for (c, n) in &countries {
let enum_name = c[0..1].to_owned().to_uppercase() + &c[1..].to_owned().to_lowercase(); let enum_name = c[0..1].to_owned().to_uppercase() + &c[1..].to_owned().to_lowercase();
@ -295,6 +272,9 @@ pub const COUNTRIES: [Country; {}] = [
writeln!(code_countries, " /// {n}").unwrap(); writeln!(code_countries, " /// {n}").unwrap();
writeln!(code_countries, " {enum_name},").unwrap(); writeln!(code_countries, " {enum_name},").unwrap();
// Country array
writeln!(code_country_array, " Country::{enum_name},").unwrap();
// Country names // Country names
writeln!( writeln!(
code_country_names, code_country_names,
@ -303,16 +283,6 @@ pub const COUNTRIES: [Country; {}] = [
.unwrap(); .unwrap();
} }
// Country array
let countries_by_name = countries
.iter()
.map(|(k, v)| (v, k))
.collect::<BTreeMap<_, _>>();
for c in countries_by_name.values() {
let enum_name = c[0..1].to_owned().to_uppercase() + &c[1..].to_owned().to_lowercase();
writeln!(code_country_array, " Country::{enum_name},").unwrap();
}
// Add Country::Zz / Global // Add Country::Zz / Global
code_countries += " /// Global (can only be used for music charts)\n"; code_countries += " /// Global (can only be used for music charts)\n";
code_countries += " Zz,\n"; code_countries += " Zz,\n";
@ -339,7 +309,7 @@ async fn get_locales() -> (BTreeMap<String, String>, BTreeMap<String, String>) {
.post("https://www.youtube.com/youtubei/v1/account/account_menu?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false") .post("https://www.youtube.com/youtubei/v1/account/account_menu?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false")
.header(header::CONTENT_TYPE, "application/json") .header(header::CONTENT_TYPE, "application/json")
.body( .body(
r#"{"context":{"client":{"clientName":"WEB","clientVersion":"2.20220914.06.00","platform":"DESKTOP","originalUrl":"https://www.youtube.com/","hl":"en","gl":"US"},"request":{"internalExperimentFlags":[],"useSsl":true},"user":{"lockedSafetyMode":false}}}"# r##"{"context":{"client":{"clientName":"WEB","clientVersion":"2.20220914.06.00","platform":"DESKTOP","originalUrl":"https://www.youtube.com/","hl":"en","gl":"US"},"request":{"internalExperimentFlags":[],"useSsl":true},"user":{"lockedSafetyMode":false}}}"##
) )
.send().await .send().await
.unwrap() .unwrap()

View file

@ -2,11 +2,8 @@
mod abtest; mod abtest;
mod collect_album_types; mod collect_album_types;
mod collect_chan_prefixes;
mod collect_history_dates;
mod collect_large_numbers; mod collect_large_numbers;
mod collect_playlist_dates; mod collect_playlist_dates;
mod collect_video_dates;
mod collect_video_durations; mod collect_video_durations;
mod download_testfiles; mod download_testfiles;
mod gen_dictionary; mod gen_dictionary;
@ -30,16 +27,10 @@ enum Commands {
CollectLargeNumbers, CollectLargeNumbers,
CollectAlbumTypes, CollectAlbumTypes,
CollectVideoDurations, CollectVideoDurations,
CollectVideoDates,
CollectHistoryDates,
CollectMusicHistoryDates,
CollectChanPrefixes,
ParsePlaylistDates, ParsePlaylistDates,
ParseHistoryDates,
ParseLargeNumbers, ParseLargeNumbers,
ParseAlbumTypes, ParseAlbumTypes,
ParseVideoDurations, ParseVideoDurations,
ParseChanPrefixes,
GenLocales, GenLocales,
GenDict, GenDict,
DownloadTestfiles, DownloadTestfiles,
@ -53,7 +44,7 @@ enum Commands {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); env_logger::init();
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
@ -69,24 +60,10 @@ async fn main() {
Commands::CollectVideoDurations => { Commands::CollectVideoDurations => {
collect_video_durations::collect_video_durations(cli.concurrency).await; collect_video_durations::collect_video_durations(cli.concurrency).await;
} }
Commands::CollectVideoDates => {
collect_video_dates::collect_video_dates(cli.concurrency).await;
}
Commands::CollectHistoryDates => {
collect_history_dates::collect_dates().await;
}
Commands::CollectMusicHistoryDates => {
collect_history_dates::collect_dates_music().await;
}
Commands::CollectChanPrefixes => {
collect_chan_prefixes::collect_chan_prefixes().await;
}
Commands::ParsePlaylistDates => collect_playlist_dates::write_samples_to_dict(), Commands::ParsePlaylistDates => collect_playlist_dates::write_samples_to_dict(),
Commands::ParseHistoryDates => collect_history_dates::write_samples_to_dict(),
Commands::ParseLargeNumbers => collect_large_numbers::write_samples_to_dict(), Commands::ParseLargeNumbers => collect_large_numbers::write_samples_to_dict(),
Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(), Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(),
Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(), Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(),
Commands::ParseChanPrefixes => collect_chan_prefixes::write_samples_to_dict(),
Commands::GenLocales => { Commands::GenLocales => {
gen_locales::generate_locales().await; gen_locales::generate_locales().await;
} }

View file

@ -1,7 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use ordered_hash_map::OrderedHashMap; use rustypipe::{client::YTContext, model::AlbumType, param::Language};
use rustypipe::{model::AlbumType, param::Language};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DefaultOnError, VecSkipError}; use serde_with::{serde_as, DefaultOnError, VecSkipError};
@ -13,20 +12,13 @@ pub struct DictEntry {
/// Should the language be parsed by character instead of by word? /// Should the language be parsed by character instead of by word?
/// (e.g. Chinese/Japanese) /// (e.g. Chinese/Japanese)
pub by_char: bool, pub by_char: bool,
/// True if the month has to be parsed before the day
///
/// Examples:
///
/// - 03.01.2020 => DMY => false
/// - 01/03/2020 => MDY => true
pub month_before_day: bool,
/// Tokens for parsing timeago strings. /// Tokens for parsing timeago strings.
/// ///
/// Format: Parsed token -> \[Quantity\] Identifier /// Format: Parsed token -> \[Quantity\] Identifier
/// ///
/// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay), /// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay),
/// `h`(our), `m`(inute), `s`(econd) /// `h`(our), `m`(inute), `s`(econd)
pub timeago_tokens: OrderedHashMap<String, String>, pub timeago_tokens: BTreeMap<String, String>,
/// Order in which to parse numeric date components. Formatted as /// Order in which to parse numeric date components. Formatted as
/// a string of date identifiers (Y, M, D). /// a string of date identifiers (Y, M, D).
/// ///
@ -42,7 +34,7 @@ pub struct DictEntry {
/// Tokens for parsing date strings with no digits (e.g. Today, Tomorrow) /// Tokens for parsing date strings with no digits (e.g. Today, Tomorrow)
/// ///
/// Format: Parsed token -> \[Quantity\] Identifier /// Format: Parsed token -> \[Quantity\] Identifier
pub timeago_nd_tokens: OrderedHashMap<String, String>, pub timeago_nd_tokens: BTreeMap<String, String>,
/// Are commas (instead of points) used as decimal separators? /// Are commas (instead of points) used as decimal separators?
pub comma_decimal: bool, pub comma_decimal: bool,
/// Tokens for parsing decimal prefixes (K, M, B, ...) /// Tokens for parsing decimal prefixes (K, M, B, ...)
@ -57,10 +49,6 @@ pub struct DictEntry {
/// ///
/// Format: Parsed text -> Album type /// Format: Parsed text -> Album type
pub album_types: BTreeMap<String, AlbumType>, pub album_types: BTreeMap<String, AlbumType>,
/// Channel name prefix on playlist pages (e.g. `by`)
pub chan_prefix: String,
/// Channel name suffix on playlist pages
pub chan_suffix: String,
} }
/// Parsed TimeAgo string, contains amount and time unit. /// Parsed TimeAgo string, contains amount and time unit.
@ -74,12 +62,12 @@ pub struct TimeAgo {
pub unit: TimeUnit, pub unit: TimeUnit,
} }
impl std::fmt::Display for TimeAgo { impl ToString for TimeAgo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn to_string(&self) -> String {
if self.n > 1 { if self.n > 1 {
write!(f, "{}{}", self.n, self.unit.as_str()) format!("{}{}", self.n, self.unit.as_str())
} else { } else {
f.write_str(self.unit.as_str()) self.unit.as_str().to_owned()
} }
} }
} }
@ -95,8 +83,6 @@ pub enum TimeUnit {
Week, Week,
Month, Month,
Year, Year,
LastWeek,
LastWeekday,
} }
impl TimeUnit { impl TimeUnit {
@ -109,24 +95,14 @@ impl TimeUnit {
TimeUnit::Week => "W", TimeUnit::Week => "W",
TimeUnit::Month => "M", TimeUnit::Month => "M",
TimeUnit::Year => "Y", TimeUnit::Year => "Y",
TimeUnit::LastWeek => "Wl",
TimeUnit::LastWeekday => "Wd",
} }
} }
} }
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtItemType {
Track,
Video,
Episode,
Playlist,
Artist,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct QBrowse<'a> { pub struct QBrowse<'a> {
pub context: YTContext<'a>,
pub browse_id: &'a str, pub browse_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<&'a str>, pub params: Option<&'a str>,
@ -135,6 +111,7 @@ pub struct QBrowse<'a> {
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct QCont<'a> { pub struct QCont<'a> {
pub context: YTContext<'a>,
pub continuation: &'a str, pub continuation: &'a str,
} }
@ -152,7 +129,7 @@ pub struct Text {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Channel { pub struct Channel {
pub contents: TwoColumnBrowseResults, pub contents: Contents,
pub header: ChannelHeader, pub header: ChannelHeader,
} }
@ -170,7 +147,7 @@ pub struct HeaderRenderer {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TwoColumnBrowseResults { pub struct Contents {
pub two_column_browse_results_renderer: TabsRenderer, pub two_column_browse_results_renderer: TabsRenderer,
} }
@ -179,37 +156,24 @@ pub struct TwoColumnBrowseResults {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TabsRenderer { pub struct TabsRenderer {
#[serde_as(as = "VecSkipError<_>")] #[serde_as(as = "VecSkipError<_>")]
pub tabs: Vec<Tab<RichGrid>>, pub tabs: Vec<TabRendererWrap>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ContentsRenderer<T> { pub struct TabRendererWrap {
#[serde(alias = "tabs")] pub tab_renderer: TabRenderer,
pub contents: Vec<T>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Tab<T> { pub struct TabRenderer {
pub tab_renderer: TabRenderer<T>, pub content: RichGridRendererWrap,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TabRenderer<T> { pub struct RichGridRendererWrap {
pub content: T,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SectionList<T> {
pub section_list_renderer: ContentsRenderer<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RichGrid {
pub rich_grid_renderer: RichGridRenderer, pub rich_grid_renderer: RichGridRenderer,
} }

View file

@ -77,7 +77,7 @@ pub fn filter_datestr(string: &str) -> String {
.to_lowercase() .to_lowercase()
.chars() .chars()
.filter_map(|c| { .filter_map(|c| {
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() { if c == '\u{200b}' || c.is_ascii_digit() {
None None
} else if c == '-' { } else if c == '-' {
Some(' ') Some(' ')
@ -134,16 +134,12 @@ where
if c.is_ascii_digit() { if c.is_ascii_digit() {
buf.push(c); buf.push(c);
} else if !buf.is_empty() { } else if !buf.is_empty() {
if let Ok(n) = buf.parse::<F>() { buf.parse::<F>().map_or((), |n| numbers.push(n));
numbers.push(n);
}
buf.clear(); buf.clear();
} }
} }
if !buf.is_empty() { if !buf.is_empty() {
if let Ok(n) = buf.parse::<F>() { buf.parse::<F>().map_or((), |n| numbers.push(n));
numbers.push(n);
}
} }
numbers numbers
@ -190,7 +186,7 @@ pub fn parse_largenum_en(string: &str) -> Option<u64> {
/// and return the duration in seconds. /// and return the duration in seconds.
pub fn parse_video_length(text: &str) -> Option<u32> { pub fn parse_video_length(text: &str) -> Option<u32> {
static VIDEO_LENGTH_REGEX: Lazy<Regex> = static VIDEO_LENGTH_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:(\d+)[:.])?(\d{1,2})[:.](\d{2})").unwrap()); Lazy::new(|| Regex::new(r#"(?:(\d+)[:.])?(\d{1,2})[:.](\d{2})"#).unwrap());
VIDEO_LENGTH_REGEX.captures(text).map(|cap| { VIDEO_LENGTH_REGEX.captures(text).map(|cap| {
let hrs = cap let hrs = cap
.get(1) .get(1)

View file

@ -1,168 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v0.3.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.7..rustypipe-downloader/v0.3.0) - 2025-02-09
### 🚀 Features
- [**breaking**] Remove manual PO token options from downloader in favor of rustypipe-botguard - ([cddb32f](https://codeberg.org/ThetaDev/rustypipe/commit/cddb32f190276265258c6ab45b3d43a8891c4b39))
### 🐛 Bug Fixes
- Ensure downloader futures are send - ([812ff4c](https://codeberg.org/ThetaDev/rustypipe/commit/812ff4c5bafffc5708a6d5066f1ebadb6d9fc958))
- Download audio with dolby codec - ([9234005](https://codeberg.org/ThetaDev/rustypipe/commit/92340056f868007beccb64e9e26eb39abc40f7aa))
### 🚜 Refactor
- [**breaking**] Add client_type field to DownloadError, rename cli option po-token-cache to pot-cache - ([594e675](https://codeberg.org/ThetaDev/rustypipe/commit/594e675b39efc5fbcdbd5e920a4d2cdee64f718e))
### 📚 Documentation
- Add Botguard info to README - ([9957add](https://codeberg.org/ThetaDev/rustypipe/commit/9957add2b5d6391b2c1869d2019fd7dd91b8cd41))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rustypipe to 0.10.0
## [v0.2.7](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.6..rustypipe-downloader/v0.2.7) - 2025-01-16
### 🚀 Features
- Extract player DRM data - ([2af4001](https://codeberg.org/ThetaDev/rustypipe/commit/2af4001c75f2ff4f7c891aa59ac22c2c6b7902a2))
- Prefer maxresdefault.jpg thumbnail if available - ([a8e97f4](https://codeberg.org/ThetaDev/rustypipe/commit/a8e97f411a1e769e52d8cbde11f0a4ca1535f7ef))
- Add DRM and audio channel number filtering to StreamFilter - ([d5abee2](https://codeberg.org/ThetaDev/rustypipe/commit/d5abee275300ab1bc10fc8d6c35a4e3813fd2bd4))
### 🐛 Bug Fixes
- Remove Unix file metadata usage (Windows compatibility) - ([5c6d992](https://codeberg.org/ThetaDev/rustypipe/commit/5c6d992939f55a203ac1784f1e9175ac1d498ce8))
### 📚 Documentation
- Update README - ([0432477](https://codeberg.org/ThetaDev/rustypipe/commit/0432477451ecd5f64145d65239c721f4e44826c0))
- Fix README - ([11442df](https://codeberg.org/ThetaDev/rustypipe/commit/11442dfd369599396357f5b7a7a4268a7b537f57))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rustypipe to 0.9.0
- *(deps)* Update rust crate rstest to 0.24.0 (#20) - ([ab19034](https://codeberg.org/ThetaDev/rustypipe/commit/ab19034ab19baf090e83eada056559676ffdadce))
- *(deps)* Update rust crate lofty to 0.22.0 - ([addeb82](https://codeberg.org/ThetaDev/rustypipe/commit/addeb821101aa968b95455604bc13bd24f50328f))
- *(deps)* Update rust crate dirs to v6 (#24) - ([6a60425](https://codeberg.org/ThetaDev/rustypipe/commit/6a604252b1af7a9388db5dc170f737069cc31051))
## [v0.2.6](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.5..rustypipe-downloader/v0.2.6) - 2024-12-20
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rustypipe to 0.8.0
## [v0.2.5](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.4..rustypipe-downloader/v0.2.5) - 2024-12-13
### 🐛 Bug Fixes
- Replace futures dependency with futures-util - ([5c39bf4](https://codeberg.org/ThetaDev/rustypipe/commit/5c39bf4842b13d37a4277ea5506e15c179892ce5))
- Remove empty tempfile after unsuccessful download - ([5262bec](https://codeberg.org/ThetaDev/rustypipe/commit/5262becca1e9e3e8262833764ef18c23bc401172))
### ⚙️ Miscellaneous Tasks
- Add docs badge to README - ([706e881](https://codeberg.org/ThetaDev/rustypipe/commit/706e88134c0e94ce7d880735e9d31b3ff531a4f9))
## [v0.2.4](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.3..rustypipe-downloader/v0.2.4) - 2024-11-10
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate thiserror to v2 (#16) - ([e1e1687](https://codeberg.org/ThetaDev/rustypipe/commit/e1e1687605603686ac5fd5deeb6aa8fecaf92494))
- *(deps)* Update rustypipe to 0.7.0
## [v0.2.3](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.2..rustypipe-downloader/v0.2.3) - 2024-10-28
### 🐛 Bug Fixes
- Remove unnecessary image.rs dependencies - ([1b08166](https://codeberg.org/ThetaDev/rustypipe/commit/1b08166399cccb8394d2fdd82d54162c1a9e01be))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rustypipe to 0.6.0
## [v0.2.2](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.1..rustypipe-downloader/v0.2.2) - 2024-10-13
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rstest to 0.23.0 (#12) - ([96776e9](https://codeberg.org/ThetaDev/rustypipe/commit/96776e98d76fa1d31d5f84dbceafbe8f9dfd9085))
- *(deps)* Update rustypipe to 0.5.0
## [v0.2.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.2.0..rustypipe-downloader/v0.2.1) - 2024-09-10
### 📚 Documentation
- Fix license badge URL - ([4a253e1](https://codeberg.org/ThetaDev/rustypipe/commit/4a253e1a47317e9999e6ad31ac5c411956a0986a))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate tokio to 1.20.4 [security] (#10) - ([a445e51](https://codeberg.org/ThetaDev/rustypipe/commit/a445e51b54a9afc44cd9657260a0b3d2abddbfa6))
## [v0.2.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.1.1..rustypipe-downloader/v0.2.0) - 2024-08-18
### 🚀 Features
- Overhauled downloader - ([11a0038](https://codeberg.org/ThetaDev/rustypipe/commit/11a00383502917cd98245c3da349107289ba3aa9))
- [**breaking**] Add TV client - ([e608811](https://codeberg.org/ThetaDev/rustypipe/commit/e608811e5f5615416241e67561671330097092cb))
- Downloader: add audio tagging - ([1e1315a](https://codeberg.org/ThetaDev/rustypipe/commit/1e1315a8378bd0ad25b5f1614e83dabc4a0b40d5))
- Downloader: add download_track fn, improve path templates - ([e1e4fb2](https://codeberg.org/ThetaDev/rustypipe/commit/e1e4fb29c190fec07f17c59ec88bef4f1c2a76a1))
- Add audiotag+indicatif features to downloader - ([97fb057](https://codeberg.org/ThetaDev/rustypipe/commit/97fb0578b5c4954a596d8dee0c4b6e1d773a9300))
- Add plaintext output to CLI - ([91b020e](https://codeberg.org/ThetaDev/rustypipe/commit/91b020efd498eff6e0f354a1de39439e252a79dd))
- Add potoken option to downloader - ([904f821](https://codeberg.org/ThetaDev/rustypipe/commit/904f8215d84c810b04e4d2134718e786a4803ad2))
- Add list of clients to downloader - ([5e646af](https://codeberg.org/ThetaDev/rustypipe/commit/5e646afd1edc6c0101501311527ea56d3bad5fd2))
- Retry with different client after 403 error - ([d875b54](https://codeberg.org/ThetaDev/rustypipe/commit/d875b5442de9822ba7ddc6f05789f56a8962808c))
- [**breaking**] Update channel model, addd handle + video_count, remove tv/mobile banner - ([e671570](https://codeberg.org/ThetaDev/rustypipe/commit/e6715700d950912031d5fbc1263f8770b6ffc49c))
### 🐛 Bug Fixes
- *(deps)* Update quick-xml to v0.35.0 - ([298e4de](https://codeberg.org/ThetaDev/rustypipe/commit/298e4def93d1595fba91be103f014aa645a08937))
- Improve deobfuscator (support multiple nsig name matches, error if mapping all streams fails) - ([8152ce6](https://codeberg.org/ThetaDev/rustypipe/commit/8152ce6b088b57be9b8419b754aca93805e5f34d))
- Set tracing instrumentation level to Error - ([9da3b25](https://codeberg.org/ThetaDev/rustypipe/commit/9da3b25be2b2577f7bd0282c09d10d368ac8b73f))
- Add docs.rs feature attributes - ([ec13cbb](https://codeberg.org/ThetaDev/rustypipe/commit/ec13cbb1f35081118dda0f7f35e3ef90f7ca79a8))
- Show docs.rs feature flags - ([67a231d](https://codeberg.org/ThetaDev/rustypipe/commit/67a231d6d1b6427f500667729a59032f2b28cc65))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate quick-xml to 0.36.0 (#8) - ([b6bc05c](https://codeberg.org/ThetaDev/rustypipe/commit/b6bc05c1f39da9a846b2e3d1d24bcbccb031203b))
- *(deps)* Update rust crate rstest to 0.22.0 (#9) - ([abb7832](https://codeberg.org/ThetaDev/rustypipe/commit/abb783219aba4b492c1dff03c2148acf1f51a55d))
- Change repo URL to Codeberg - ([1793331](https://codeberg.org/ThetaDev/rustypipe/commit/17933315d947f76d5fe1aa52abf7ea24c3ce6381))
- Adjust dependency versions - ([70c6f8c](https://codeberg.org/ThetaDev/rustypipe/commit/70c6f8c3b97baefd316fff90cc727524516657af))
### Todo
- Update metadata - ([8692ca8](https://codeberg.org/ThetaDev/rustypipe/commit/8692ca81d972d0d2acf6fb4da79b9e0f5ebf4daf))
## [v0.1.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.1.0..rustypipe-downloader/v0.1.1) - 2024-06-27
### 📚 Documentation
- Add logo - ([6646078](https://codeberg.org/ThetaDev/rustypipe/commit/66460789449be0d5984cbdb6ec372e69323b7a88))
### ⚙️ Miscellaneous Tasks
- Changelog: fix incorrect version URLs - ([97b6f07](https://codeberg.org/ThetaDev/rustypipe/commit/97b6f07399e80e00a6c015d013e744568be125dd))
- Update rstest to v0.19.0 - ([50fd1f0](https://codeberg.org/ThetaDev/rustypipe/commit/50fd1f08caf39c1298654e06059cc393543e925b))
- Introduce MSRV - ([5dbb288](https://codeberg.org/ThetaDev/rustypipe/commit/5dbb288a496d53a299effa2026f5258af7b1f176))
- Fix clippy lints - ([45b9f2a](https://codeberg.org/ThetaDev/rustypipe/commit/45b9f2a627b4e7075ba0b1c5f16efcc19aef7922))
- *(deps)* Update rust crate tokio to 1.20.4 [security] (#4) - ([ce3ec34](https://codeberg.org/ThetaDev/rustypipe/commit/ce3ec34337b8acac41410ea39264aab7423d5801))
- *(deps)* Update rust crate quick-xml to 0.34.0 (#5) - ([1e8a1af](https://codeberg.org/ThetaDev/rustypipe/commit/1e8a1af08c873cee7feadf63c2eff62753a78f64))
- *(deps)* Update rust crate rstest to 0.21.0 (#7) - ([c3af918](https://codeberg.org/ThetaDev/rustypipe/commit/c3af918ba53c6230c0e4aef822a0cb2cf120bf3f))
- Update rustypipe to 0.2.0
## [v0.1.0](https://codeberg.org/ThetaDev/rustypipe/commits/tag/rustypipe-downloader/v0.1.0) - 2024-03-22
Initial release
<!-- generated by git-cliff -->

View file

@ -1,14 +1,12 @@
[package] [package]
name = "rustypipe-downloader" name = "rustypipe-downloader"
version = "0.3.0" version = "0.1.0"
rust-version = "1.67.1" edition = "2021"
edition.workspace = true authors = ["ThetaDev <t.testboy@gmail.com>"]
authors.workspace = true license = "GPL-3.0"
license.workspace = true
repository.workspace = true
keywords.workspace = true
categories.workspace = true
description = "Downloader extension for RustyPipe" description = "Downloader extension for RustyPipe"
keywords = ["youtube", "video", "music"]
categories = ["multimedia"]
[features] [features]
default = ["default-tls"] default = ["default-tls"]
@ -30,37 +28,18 @@ rustls-tls-native-roots = [
"rustypipe/rustls-tls-native-roots", "rustypipe/rustls-tls-native-roots",
] ]
audiotag = ["dep:lofty", "dep:image", "dep:smartcrop2"]
[dependencies] [dependencies]
rustypipe.workspace = true rustypipe = { path = "..", default-features = false }
once_cell.workspace = true rustypipe-postprocessor = { path = "../postprocessor" }
regex.workspace = true once_cell = "1.12.0"
thiserror.workspace = true regex = "1.6.0"
futures-util.workspace = true thiserror = "1.0.36"
reqwest = { workspace = true, features = ["stream"] } futures = "0.3.21"
rand.workspace = true indicatif = "0.17.0"
tokio = { workspace = true, features = ["macros", "fs", "process"] } filenamify = "0.1.0"
indicatif = { workspace = true, optional = true } log = "0.4.17"
filenamify.workspace = true reqwest = { version = "0.11.11", default-features = false, features = [
tracing.workspace = true "stream",
time.workspace = true
lofty = { version = "0.22.0", optional = true }
image = { version = "0.25.0", optional = true, default-features = false, features = [
"rayon",
"jpeg",
"webp",
] } ] }
smartcrop2 = { version = "0.3.1", optional = true } rand = "0.8.5"
tokio = { version = "1.20.0", features = ["macros", "fs", "process"] }
[dev-dependencies]
path_macro.workspace = true
rstest.workspace = true
serde_json.workspace = true
temp_testdir = "0.2.3"
[package.metadata.docs.rs]
# To build locally:
# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features indicatif,audiotag --no-deps --open
features = ["indicatif", "audiotag"]
rustdoc-args = ["--cfg", "docsrs"]

View file

@ -1,47 +0,0 @@
# ![RustyPipe](https://codeberg.org/ThetaDev/rustypipe/raw/branch/main/notes/logo.svg) Downloader
[![Current crates.io version](https://img.shields.io/crates/v/rustypipe-downloader.svg)](https://crates.io/crates/rustypipe-downloader)
[![License](https://img.shields.io/badge/License-GPL--3-blue.svg?style=flat)](https://opensource.org/licenses/GPL-3.0)
[![Docs](https://img.shields.io/docsrs/rustypipe-downloader/latest?style=flat)](https://docs.rs/rustypipe-downloader)
[![CI status](https://codeberg.org/ThetaDev/rustypipe/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/rustypipe/actions/?workflow=ci.yaml)
The downloader is a companion crate for RustyPipe that allows for easy and fast
downloading of video and audio files.
## Features
- Fast download of streams, bypassing YouTube's throttling
- Join video and audio streams using ffmpeg
- [Indicatif](https://crates.io/crates/indicatif) support to show download progress bars
(enable `indicatif` feature to use)
- Tag audio files with title, album, artist, date, description and album cover (enable
`audiotag` feature to use)
- Album covers are automatically cropped using smartcrop to ensure they are square
## How to use
For the downloader to work, you need to have ffmpeg installed on your system. If your
ffmpeg binary is located at a non-standard path, you can configure the location using
[`DownloaderBuilder::ffmpeg`].
At first you have to instantiate and configure the downloader using either
[`Downloader::new`] or the [`DownloaderBuilder`].
Then you can build a new download query with a video ID, stream filter and destination
path and finally download the video.
```rust ignore
use rustypipe::param::StreamFilter;
use rustypipe_downloader::DownloaderBuilder;
let dl = DownloaderBuilder::new()
.audio_tag()
.crop_cover()
.build();
let filter_audio = StreamFilter::new().no_video();
dl.id("eRsGyueVLvQ").stream_filter(filter_audio).to_file("audio.opus").download().await;
let filter_video = StreamFilter::new().video_max_res(720);
dl.id("eRsGyueVLvQ").stream_filter(filter_video).to_file("video.mp4").download().await;
```

View file

@ -1,59 +0,0 @@
use std::{borrow::Cow, path::PathBuf};
use rustypipe::client::ClientType;
/// Error from the video downloader
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum DownloadError {
/// RustyPipe error
#[error("{0}")]
RustyPipe(#[from] rustypipe::error::Error),
/// Error from the HTTP client
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
/// 403 error trying to download video
#[error("YouTube returned 403 error; visitor_data={}", .visitor_data.as_deref().unwrap_or_default())]
Forbidden {
/// Client type used to fetch the failed stream
client_type: ClientType,
/// Visitor data used to fetch the failed stream
visitor_data: Option<String>,
},
/// File IO error
#[error(transparent)]
Io(#[from] std::io::Error),
/// FFmpeg returned an error
#[error("FFmpeg error: {0}")]
Ffmpeg(Cow<'static, str>),
/// Error parsing ranges for progressive download
#[error("Progressive download error: {0}")]
Progressive(Cow<'static, str>),
/// Video could not be downloaded because of invalid player data
#[error("source error: {0}")]
Source(Cow<'static, str>),
/// Download target already exists
#[error("file {0} already exists")]
Exists(PathBuf),
#[cfg(feature = "audiotag")]
/// Audio tagging error
#[error("Audio tag error: {0}")]
AudioTag(Cow<'static, str>),
/// Other error
#[error("error: {0}")]
Other(Cow<'static, str>),
}
#[cfg(feature = "audiotag")]
impl From<lofty::error::LoftyError> for DownloadError {
fn from(value: lofty::error::LoftyError) -> Self {
Self::AudioTag(value.to_string().into())
}
}
#[cfg(feature = "audiotag")]
impl From<image::ImageError> for DownloadError {
fn from(value: image::ImageError) -> Self {
Self::AudioTag(value.to_string().into())
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,28 @@
use std::collections::BTreeMap; use std::{borrow::Cow, collections::BTreeMap};
use reqwest::Url; use reqwest::Url;
use crate::DownloadError; /// Error from the video downloader
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum DownloadError {
/// Error from the HTTP client
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
/// File IO error
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("FFmpeg error: {0}")]
Ffmpeg(Cow<'static, str>),
#[error("Progressive download error: {0}")]
Progressive(Cow<'static, str>),
#[error("input error: {0}")]
Input(Cow<'static, str>),
#[error("error: {0}")]
Other(Cow<'static, str>),
#[error("Postprocessing error: {0}")]
Postprocessing(#[from] rustypipe_postprocessor::PostprocessingError),
}
/// Split an URL into its base string and parameter map /// Split an URL into its base string and parameter map
/// ///

View file

@ -1,127 +0,0 @@
use std::{fs, os::unix::fs::MetadataExt, path::Path, process::Command};
use path_macro::path;
use rstest::{fixture, rstest};
use rustypipe::{client::RustyPipe, model::AudioCodec, param::StreamFilter};
use rustypipe_downloader::Downloader;
use temp_testdir::TempDir;
/// Get a new RusttyPipe instance
#[fixture]
fn rp() -> RustyPipe {
let vdata = std::env::var("YT_VDATA").ok();
RustyPipe::builder()
.strict()
.storage_dir(path!(env!("CARGO_MANIFEST_DIR") / ".."))
.visitor_data_opt(vdata)
.build()
.unwrap()
}
#[rstest]
#[tokio::test]
async fn download_video(rp: RustyPipe) {
let td = TempDir::default();
let td_path = td.to_path_buf();
let dl = Downloader::builder().rustypipe(&rp).build();
let res = dl
.id("UXqq0ZvbOnk")
.to_dir(&td_path)
.stream_filter(StreamFilter::new().video_max_res(480))
.download()
.await
.unwrap();
assert_eq!(
res.dest,
path!(td_path / "CHARGE - Blender Open Movie [UXqq0ZvbOnk].mp4")
);
assert_eq!(res.player_data.details.id, "UXqq0ZvbOnk");
}
#[rstest]
#[tokio::test]
async fn download_music(rp: RustyPipe) {
let td = TempDir::default();
let td_path = td.to_path_buf();
#[allow(unused_mut)]
let mut dl = Downloader::builder().rustypipe(&rp);
#[cfg(feature = "audiotag")]
{
dl = dl.audio_tag().crop_cover();
}
let dl = dl.build();
let res = dl
.id("bVtv3st8bgc")
.to_dir(&td_path)
.stream_filter(
StreamFilter::new()
.no_video()
.audio_codecs([AudioCodec::Opus]),
)
.download()
.await
.unwrap();
assert_eq!(
res.dest,
path!(td_path / "Lord of the Riffs [bVtv3st8bgc].opus")
);
assert_eq!(res.player_data.details.id, "bVtv3st8bgc");
let fm = fs::metadata(&res.dest).unwrap();
assert_gte(fm.size(), 6_000_000, "file size");
assert_audio_meta(
&res.dest,
"Lord of the Riffs",
"Alexander Nakarada",
"Lord of the Riffs",
"2022-02-05",
);
}
/// Assert that number A is greater than or equal to number B
#[track_caller]
fn assert_gte<T: PartialOrd + std::fmt::Display>(a: T, b: T, msg: &str) {
assert!(a >= b, "expected >= {b} {msg}, got {a}");
}
#[track_caller]
fn assert_audio_meta(p: &Path, title: &str, artist: &str, album: &str, date: &str) {
let res = Command::new("ffprobe")
.args([
"-loglevel",
"error",
"-show_entries",
"stream_tags",
"-of",
"json",
])
.arg(p)
.output()
.unwrap();
if !res.status.success() {
panic!("ffprobe error\n{}", String::from_utf8_lossy(&res.stderr))
}
let res_json = serde_json::from_slice::<serde_json::Value>(&res.stdout).unwrap();
let tags = &res_json["streams"][0]["tags"];
assert_eq!(tags["TITLE"].as_str(), Some(title));
assert_eq!(tags["ARTIST"].as_str(), Some(artist));
assert_eq!(tags["ALBUM"].as_str(), Some(album));
assert_eq!(tags["DATE"].as_str(), Some(date));
}
/// This is just a static check to make sure all RustyPipe futures can be sent
/// between threads safely.
/// Otherwise this may cause issues when integrating RustyPipe into async projects.
#[allow(unused)]
async fn all_send_and_sync() {
fn send_and_sync<T: Send + Sync>(t: T) {}
let dl = Downloader::default();
let dlq = dl.id("");
send_and_sync(dlq.download());
}

View file

@ -3,12 +3,12 @@
When YouTube introduces a new feature, it does so gradually. When a user creates a new When YouTube introduces a new feature, it does so gradually. When a user creates a new
session, YouTube decided randomly which new features should be enabled. session, YouTube decided randomly which new features should be enabled.
YouTube sessions are identified by the visitor data ID. This cookie is sent with YouTube sessions are identified by the visitor data cookie. This cookie is sent with
every API request using the `context.client.visitor_data` JSON parameter. It is also every API request using the `context.client.visitor_data` JSON parameter. It is also
returned in the `responseContext.visitorData` response parameter and stored as the returned in the `responseContext.visitorData` response parameter and stored as the
`__SECURE-YEC` cookie. `__SECURE-YEC` cookie.
By sending the same visitor data ID, A/B tests can be reproduced, which is important By sending the same visitor data cookie, A/B tests can be reproduced, which is important
for testing alternative YouTube clients. for testing alternative YouTube clients.
This page lists all A/B tests that were encountered while maintaining the RustyPipe This page lists all A/B tests that were encountered while maintaining the RustyPipe
@ -24,14 +24,6 @@ to the new feature.
- 🔴 **High** Changes to the functionality of YouTube that will require API changes for - 🔴 **High** Changes to the functionality of YouTube that will require API changes for
alternative clients alternative clients
**Status:**
- Discontinued (0%)
- Experimental (<3%)
- Common (>3%)
- Frequent (>40%)
- Stabilized (100%)
If you want to check how often these A/B tests occur, you can use the `codegen` tool If you want to check how often these A/B tests occur, you can use the `codegen` tool
with the following command: `rustypipe-codegen ab-test <id>`. with the following command: `rustypipe-codegen ab-test <id>`.
@ -40,7 +32,6 @@ with the following command: `rustypipe-codegen ab-test <id>`.
- **Encountered on:** 24.09.2022 - **Encountered on:** 24.09.2022
- **Impact:** 🟡 Medium - **Impact:** 🟡 Medium
- **Endpoint:** next (video details) - **Endpoint:** next (video details)
- **Status:** Stabilized
![A/B test 1 screenshot](./_img/ab_1.png) ![A/B test 1 screenshot](./_img/ab_1.png)
@ -130,7 +121,6 @@ UTF-16 index seperately.
- **Encountered on:** 11.10.2022 - **Encountered on:** 11.10.2022
- **Impact:** 🔴 High - **Impact:** 🔴 High
- **Endpoint:** browse (channel videos) - **Endpoint:** browse (channel videos)
- **Status:** Stabilized
![A/B test 2 screenshot](./_img/ab_2.webp) ![A/B test 2 screenshot](./_img/ab_2.webp)
@ -227,7 +217,6 @@ Additionally the channel tab response model was slightly changed, now using a
- **Encountered on:** 20.11.2022 - **Encountered on:** 20.11.2022
- **Impact:** 🟡 Medium - **Impact:** 🟡 Medium
- **Endpoint:** search - **Endpoint:** search
- **Status:** Stabilized
![A/B test 3 screenshot](./_img/ab_3.png) ![A/B test 3 screenshot](./_img/ab_3.png)
@ -287,7 +276,6 @@ Note that channels without handles still use the old data model, even on the sam
- **Encountered on:** 1.04.2023 - **Encountered on:** 1.04.2023
- **Impact:** 🟢 Low - **Impact:** 🟢 Low
- **Endpoint:** browse (trending videos) - **Endpoint:** browse (trending videos)
- **Status:** Discontinued
YouTube moved the list of trending videos from the main _trending_ page to a separate YouTube moved the list of trending videos from the main _trending_ page to a separate
tab (Videos). tab (Videos).
@ -305,14 +293,13 @@ The data model for the video shelves did not change.
**NEW** **NEW**
![A/B test 4 new screenshot](./_img/ab_4_new.png) ![A/B test 4 old screenshot](./_img/ab_4_new.png)
## [5] Page header renderer on the Trending page ## [5] Page header renderer on the Trending page
- **Encountered on:** 1.05.2023 - **Encountered on:** 1.05.2023
- **Impact:** 🟢 Low - **Impact:** 🟢 Low
- **Endpoint:** browse (trending videos) - **Endpoint:** browse (trending videos)
- **Status:** Stabilized
YouTube changed the header renderer type on the trending page to a `pageHeaderRenderer`. YouTube changed the header renderer type on the trending page to a `pageHeaderRenderer`.
@ -372,683 +359,20 @@ YouTube changed the header renderer type on the trending page to a `pageHeaderRe
- **Encountered on:** 13.05.2023 - **Encountered on:** 13.05.2023
- **Impact:** 🟡 Medium - **Impact:** 🟡 Medium
- **Endpoint:** browse (music artist) - **Endpoint:** browse (music artist)
- **Status:** Stabilized
YouTube merged the 2 sections for singles and albums on artist pages together. Now there YouTube merged the 2 sections for singles and albums on artist pages together. Now
is only a _Top Releases_ section. there is only a *Top Releases* section.
YouTube also changed the way the full discography page is fetched, surprisingly making YouTube also changed the way the full discography page is fetched, surprisingly making
it easier for alternative clients. The discography page now has its own content ID in it easier for alternative clients. The discography page now has its own content ID in
the format of `MPAD<channel id>` (Music Page Artist Discography). This page can be the format of `MPAD<channel id>` (Music Page Artist Discography). This page can be
fetched with a regular browse request without requiring parameters to be parsed or a fetched with a regular browse request without requiring parameters to be parsed or a
visitor data ID to be set, as it was the case with the old system. visitor data cookie to be set, as it was the case with the old system.
**OLD** **OLD**
![A/B test 6 old screenshot](./_img/ab_6_old.png) ![A/B test 4 old screenshot](./_img/ab_6_old.png)
**NEW** **NEW**
![A/B test 6 screenshot](./_img/ab_6_new.png) ![A/B test 4 old screenshot](./_img/ab_6_new.png)
## [7] Short timeago format
- **Encountered on:** 28.05.2023
- **Impact:** 🟢 Low
- **Status:** Discontinued
YouTube changed their date format from the long format (_21 hours ago_, _3 days ago_) to
a short format (_21h ago_, _3d ago_).
## [8] Track playback count in search results and artist views
- **Encountered on:** 29.06.2023
- **Impact:** 🟡 Medium
- **Status:** Stabilized
YouTube added the track playback count to search results and top artist tracks. In
exchange, they removed the "Song" type identifier from search results.
![A/B test 8 old screenshot](./_img/ab_8_old.png)
![A/B test 8 screenshot](./_img/ab_8.png)
## [9] Playlists for Shorts
- **Encountered on:** 26.06.2023
- **Impact:** 🟡 Medium
- **Endpoint:** browse (playlist)
- **Status:** Stabilized
![A/B test 9 screenshot](./_img/ab_9.png)
Original issue: https://github.com/TeamNewPipe/NewPipeExtractor/issues/10774
YouTube added a filter system for playlists, allowing users to only see shorts/full
videos.
When shorts filter is enabled or when there are only shorts in a playlist, YouTube
return shorts UI elements instead of standard video ones, the ones that are also used
for shorts shelves in searches and suggestions and shorts in the corresponding channel
tab.
Since the reel items dont include upload date information you can circumvent this new UI
by using the mobile client. But that may change in the future.
## [10] Channel About modal
- **Encountered on:** 03.11.2023
- **Impact:** 🟡 Medium
- **Endpoint:** browse (channel info)
- **Status:** Stabilized
![A/B test 10 screenshot](./_img/ab_10.png)
YouTube replaced the _About_ channel tab with a modal. This changes the way additional
channel metadata has to be fetched.
The new modal uses a continuation request with a token which can be easily generated.
Attempts to fetch the old about tab with the A/B test enabled will lead to a redirect to
the main tab.
## [11] Like-Button viewmodel
- **Encountered on:** 03.11.2023
- **Impact:** 🟢 Low
- **Endpoint:** next
- **Status:** Stabilized
YouTube introduced an updated data model for the like/dislike buttons. The new model
looks needlessly complex but contains the same parsing-relevant data as the old model
(accessibility text to get like count).
```json
{
"segmentedLikeDislikeButtonViewModel": {
"likeButtonViewModel": {
"likeButtonViewModel": {
"toggleButtonViewModel": {
"toggleButtonViewModel": {
"defaultButtonViewModel": {
"buttonViewModel": {
"iconName": "LIKE",
"title": "4.2M",
"accessibilityText": "like this video along with 4,209,059 other people"
}
}
}
}
}
}
}
}
```
## [12] New channel page header
- **Encountered on:** 29.01.2024
- **Impact:** 🟢 Low
- **Endpoint:** browse
- **Status:** Stabilized
YouTube introduced a new data model for channel headers, based on a
`"pageHeaderRenderer"`. The new model comes with more needless complexity that needs to
be accomodated. There are also no mobile/TV header images available any more.
```json
{
"pageHeaderViewModel": {
"title": {
"dynamicTextViewModel": {
"text": {
"content": "Doobydobap",
"attachmentRuns": [
{
"startIndex": 10,
"length": 0,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{
"clientResource": {
"imageName": "CHECK_CIRCLE_FILLED"
},
"width": 14,
"height": 14
}
]
}
}
}
}
}
]
}
}
},
"image": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.googleusercontent.com/dm5Aq93xvVJz0NoVO88ieBkDXmuShCujGPlZ7qETMEPTrXvPUCFI3-BB6Xs_P-r6Uk3mnBy9zA=s72-c-k-c0x00ffffff-no-rj",
"width": 72,
"height": 72
}
]
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"text": {
"content": "@Doobydobap"
}
},
{
"text": {
"content": "3.74M subscribers"
}
},
{
"text": {
"content": "345 videos",
"styleRuns": [
{
"startIndex": 0,
"length": 10
}
]
}
}
]
}
]
}
},
"banner": {
"imageBannerViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
"width": 1060,
"height": 175
}
]
}
}
}
}
}
```
## [13] Music album/playlist 2-column layout
- **Encountered on:** 29.02.2024
- **Impact:** 🟢 Low
- **Endpoint:** browse
- **Status:** Stabilized
![A/B test 13 screenshot](./_img/ab_13.png)
YouTube Music updated the layout of album and playlist pages. The new layout shows the
cover on the left side of the playlist content.
## [14] Comments Framework update
- **Encountered on:** 31.01.2024
- **Impact:** 🟢 Low
- **Endpoint:** next
- **Status:** Stabilized
YouTube changed the data model for YouTube comments, now putting the content into a
seperate framework update object
```json
{
"frameworkUpdates": {
"onResponseReceivedEndpoints": [
{
"clickTrackingParams": "CAAQg2ciEwi64q3dmKGFAxWvy0IFHc14BKM=",
"reloadContinuationItemsCommand": {
"targetId": "comments-section",
"continuationItems": [
{
"commentThreadRenderer": {
"replies": {
"commentRepliesRenderer": {
"contents": [
{
"continuationItemRenderer": {
"trigger": "CONTINUATION_TRIGGER_ON_ITEM_SHOWN",
"continuationEndpoint": {
"clickTrackingParams": "CHgQvnUiEwi64q3dmKGFAxWvy0IFHc14BKM=",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/next"
}
},
"continuationCommand": {
"token": "Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5TlRUOHV4REVqZ1lxeWJJRjRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZw%3D%3D",
"request": "CONTINUATION_REQUEST_TYPE_WATCH_NEXT"
}
}
}
}
],
"trackingParams": "CHgQvnUiEwi64q3dmKGFAxWvy0IFHc14BKM=",
"viewReplies": {
"buttonRenderer": {
"text": { "runs": [{ "text": "220 replies" }] },
"icon": { "iconType": "ARROW_DROP_DOWN" },
"trackingParams": "CHoQosAEIhMIuuKt3ZihhQMVr8tCBR3NeASj",
"iconPosition": "BUTTON_ICON_POSITION_TYPE_LEFT_OF_TEXT"
}
},
"hideReplies": {
"buttonRenderer": {
"text": { "runs": [{ "text": "220 replies" }] },
"icon": { "iconType": "ARROW_DROP_UP" },
"trackingParams": "CHkQ280EIhMIuuKt3ZihhQMVr8tCBR3NeASj",
"iconPosition": "BUTTON_ICON_POSITION_TYPE_LEFT_OF_TEXT"
}
},
"targetId": "comment-replies-item-UgyNTT8uxDEjgYqybIF4AaABAg"
}
},
"trackingParams": "CHYQwnUYywEiEwi64q3dmKGFAxWvy0IFHc14BKM=",
"renderingPriority": "RENDERING_PRIORITY_PINNED_COMMENT",
"isModeratedElqComment": false,
"commentViewModel": {
"commentViewModel": {
"commentId": "UgyNTT8uxDEjgYqybIF4AaABAg"
}
}
}
}
]
}
}
],
"entityBatchUpdate": {
"mutations": [
{
"entityKey": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyAoKAE%3D",
"type": "ENTITY_MUTATION_TYPE_REPLACE",
"payload": {
"commentEntityPayload": {
"key": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyAoKAE%3D",
"properties": {
"commentId": "UgyNTT8uxDEjgYqybIF4AaABAg",
"content": {
"content": "⚠️ Important notice: if you put any symbol immediately after markup, it will not work: *here is the comma*, without space.\n\nYou should leave space before and after , to make it work.\n\nSame for _underscore_, and -hyphen-.\n\nLeave space before opening and after closing underscore and hyphen. Put all dots and commas inside markup.",
"styleRuns": [
{
"startIndex": 135,
"length": 28,
"weightLabel": "FONT_WEIGHT_MEDIUM"
},
{
"startIndex": 267,
"length": 10,
"weightLabel": "FONT_WEIGHT_NORMAL",
"italic": true
},
{
"startIndex": 282,
"length": 7,
"weightLabel": "FONT_WEIGHT_NORMAL",
"strikethrough": "LINE_STYLE_SINGLE"
}
]
},
"publishedTime": "2 years ago (edited)",
"replyLevel": 0,
"authorButtonA11y": "@kibizoid",
"toolbarStateKey": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyAsKAE%3D",
"translateButtonEntityKey": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyD_ASgB"
},
"author": {
"channelId": "UCUJfyiofeHQTmxKwZ6cCwIg",
"displayName": "@kibizoid",
"avatarThumbnailUrl": "https://yt3.ggpht.com/ytc/AIdro_nY2PkIyojDqs9Bk5RY6J90-U7wePswTYl799DNJQ=s88-c-k-c0x00ffffff-no-rj",
"isVerified": false,
"isCurrentUser": false,
"isCreator": false,
"isArtist": false
},
"avatar": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_nY2PkIyojDqs9Bk5RY6J90-U7wePswTYl799DNJQ=s88-c-k-c0x00ffffff-no-rj",
"width": 88,
"height": 88
}
]
}
}
}
}
}
]
}
}
}
```
## [15] Channel shorts: shortsLockupViewModel
- **Encountered on:** 10.09.2024
- **Impact:** 🟢 Low
- **Endpoint:** browse
- **Status:** Stabilized
YouTube changed the data model for the channel shorts tab
```json
{
"richItemRenderer": {
"content": {
"shortsLockupViewModel": {
"entityId": "shorts-shelf-item-ovaHmfy3O6U",
"accessibilityText": "hangover food, 17 million views - play Short",
"thumbnail": {
"sources": [
{
"url": "https://i.ytimg.com/vi/ovaHmfy3O6U/oar2.jpg?sqp=-oaymwEdCJUDENAFSFWQAgHyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBg-kG4rAi-BQ8Xkp2hOtOu-oXDLQ",
"width": 405,
"height": 720
}
]
},
"overlayMetadata": {
"primaryText": {
"content": "hangover food"
},
"secondaryText": {
"content": "17M views"
}
}
}
}
}
}
```
## [16] New playlist header renderer
- **Encountered on:** 11.10.2024
- **Impact:** 🟢 Low
- **Endpoint:** browse
- **Status:** Stabilized
```json
{
"pageHeaderRenderer": {
"pageTitle": "LilyPichu",
"content": {
"pageHeaderViewModel": {
"title": {
"dynamicTextViewModel": {
"text": {
"content": "LilyPichu"
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"avatarStack": {
"avatarStackViewModel": {
"avatars": [
{
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_kcjhSY2e8WlYjQABOB65Za8n3QYycNHP9zXwxjKpBfOg=s48-c-k-c0x00ffffff-no-rj",
"width": 48,
"height": 48
}
]
}
}
}
],
"text": {
"content": "by Kevin Ramirez",
"commandRuns": [
{
"startIndex": 0,
"length": 16,
"onTap": {
"innertubeCommand": {
"browseEndpoint": {
"browseId": "UCai7BcI5lrXC2vdc3ySku8A",
"canonicalBaseUrl": "/@XxthekevinramirezxX"
}
}
}
}
]
}
}
}
}
]
},
{
"metadataParts": [
{
"text": {
"content": "Playlist"
}
},
{
"text": {
"content": "10 videos"
}
},
{
"text": {
"content": "856 views"
}
}
]
}
]
}
},
"actions": {},
"description": {
"descriptionPreviewViewModel": {
"description": { "content": "Hello World" }
}
},
"heroImage": {
"contentPreviewImageViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/DXuNJ267Vss/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLAHp6V96b70x4SWm9Pe6WEHnQhP6A",
"width": 168,
"height": 94
}
]
}
}
}
}
}
}
}
```
## [17] Channel playlists: lockupViewModel
- **Encountered on:** 09.11.2024
- **Impact:** 🟢 Low
- **Endpoint:** browse
- **Status:** Stabilized
YouTube changed the data model for the channel playlists / podcasts / albums tab
```json
{
"lockupViewModel": {
"contentImage": {
"collectionThumbnailViewModel": {
"primaryThumbnail": {
"thumbnailViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/XYdmX8w8xwI/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCqmf6TGfDinNXhgU29ZxOkv2u9sQ",
"width": 480,
"height": 270
}
]
},
"overlays": [
{
"thumbnailOverlayBadgeViewModel": {
"thumbnailBadges": [
{
"thumbnailBadgeViewModel": {
"icon": {
"sources": [
{
"clientResource": {
"imageName": "PLAYLISTS"
}
}
]
},
"text": "5 videos",
"badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
"backgroundColor": {
"lightTheme": 2370867,
"darkTheme": 2370867
}
}
}
],
"position": "THUMBNAIL_OVERLAY_BADGE_POSITION_BOTTOM_END"
}
}
]
}
}
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {
"content": "Jellybean Components Series"
}
}
},
"contentId": "PLvOlSehNtuHv268f0mW5m1t_hq_RVGRSA",
"contentType": "LOCKUP_CONTENT_TYPE_PLAYLIST"
}
}
```
## [18] Music playlists facepile avatar
- **Encountered on:** 25.11.2024
- **Impact:** 🟢 Low
- **Endpoint:** browse (YTM)
- **Status:** Stabilized
YouTube changed the data model for the channel playlist owner avatar into a `facepile`
object. It now also contains the channel avatar.
The model is also used for playlists owned by YouTube Music (with the avatar and
commandContext missing).
```json
{
"facepile": {
"avatarStackViewModel": {
"avatars": [
{
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_n9ALaLETwQH6_2WlXitIaIKV-IqBDWWquvyI2jucNAZaQ=s48-c-k-c0x00000000-no-cc-rj-rp"
}
]
},
"avatarImageSize": "AVATAR_SIZE_XS"
}
}
],
"text": {
"content": "Chaosflo44"
},
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {
"browseId": "UCQM0bS4_04-Y4JuYrgmnpZQ",
"browseEndpointContextSupportedConfigs": {
"browseEndpointContextMusicConfig": {
"pageType": "MUSIC_PAGE_TYPE_USER_CHANNEL"
}
}
}
}
}
}
}
}
}
}
```
## [19] Music artist album groups reordered
- **Encountered on:** 13.01.2025
- **Impact:** 🟢 Low
- **Endpoint:** browse (YTM)
- **Status:** Common (10%)
YouTube Music used to group artist albums into 2 rows: "Albums" and "Singles".
These groups were changed into "Albums" and "Singles & EPs". Now the "Album" label is
omitted for albums in their group, while singles and EPs have a label with their type.
## [20] Music continuation item renderer
- **Encountered on:** 25.01.2025
- **Impact:** 🟢 Low
- **Endpoint:** browse (YTM)
- **Status:** Common (4%)
YouTube Music now uses a `continuationItemRenderer` for music playlists instead of
putting the continuations in a separate attribute of the MusicShelf.
The continuation response now uses a `onResponseReceivedActions` field for its music
items.
YouTube Music now also sends a random 16-character string as a `clientScreenNonce` in
the request context. This is not mandatory though.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,110 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="530"
height="80"
viewBox="0 0 140.22916 21.166667"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="logo.svg"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="1.329974"
inkscape:cx="206.77097"
inkscape:cy="117.29553"
inkscape:window-width="2516"
inkscape:window-height="1051"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" /><defs
id="defs2" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><g
aria-label="RUSTYPIPE"
id="text236"
style="font-size:21.1667px;line-height:1.25;display:inline;stroke-width:0.264583"
transform="translate(-22.622596,-15.875)"><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 51.720162,28.78667 h -0.846668 v -3.238506 h 0.719667 q 0.656168,0 0.994835,-0.04233 0.338668,-0.0635 0.529168,-0.211667 0.169333,-0.148167 0.232834,-0.444501 0.0635,-0.296334 0.0635,-0.867834 0,-0.571501 -0.0635,-0.867835 -0.0635,-0.317501 -0.232834,-0.465668 -0.169334,-0.148166 -0.508001,-0.1905 -0.3175,-0.04233 -1.016002,-0.04233 h -0.719667 v -3.238505 h 2.18017 q 1.502835,0 2.43417,0.296333 0.931335,0.296334 1.439336,0.910169 0.465667,0.5715 0.613834,1.418168 0.169334,0.846668 0.169334,2.180171 0,1.714502 -0.317501,2.645837 -0.4445,1.185335 -1.566335,1.672169 l 2.201336,5.439842 h -4.445007 z"
id="path2732" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 45.751152,19.176988 h 4.23334 v 14.562689 h -4.23334 z"
id="path2711" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 67.701016,19.176988 h 4.23334 v 7.916346 q 0,2.074336 -0.08467,3.132671 -0.08467,1.058335 -0.465667,1.778003 -0.423334,0.825501 -1.291169,1.270002 -0.867834,0.444501 -2.391837,0.571501 z"
id="path2736" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 65.94418,33.909011 q -2.222504,0 -3.429006,-0.359834 -1.206501,-0.359834 -1.778002,-1.164168 -0.529168,-0.740835 -0.656168,-1.883837 -0.127,-1.143002 -0.127,-3.407838 v -7.916346 h 4.23334 v 8.763014 q 0,0.783167 0.04233,1.502835 0.04233,0.571501 0.190501,0.825502 0.148166,0.254 0.508,0.3175 0.317501,0.08467 1.016002,0.08467 h 0.486834 q 0.169334,0 0.381001,-0.04233 v 3.259672 q -0.148167,0.02117 -0.423334,0.02117 z"
id="path2713" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 80.041215,33.909011 q -2.11667,0 -3.937006,-0.1905 -1.079502,-0.105834 -1.629836,-0.211667 v -3.090339 q 1.248835,0.105834 2.857504,0.211667 1.016002,0.04233 1.439336,0.04233 1.143002,0 1.502836,-0.0635 v 3.302005 z"
id="path2742" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 81.16305,29.442837 q 0,-0.4445 -0.0635,-0.635001 -0.0635,-0.211667 -0.211667,-0.275167 -0.148167,-0.08467 -0.508001,-0.127 l -2.603504,-0.317501 q -2.30717,-0.254 -3.111505,-1.502835 -0.359834,-0.529168 -0.486834,-1.291169 -0.127,-0.762001 -0.127,-1.86267 0,-2.349503 1.206502,-3.386672 0.973668,-0.846668 3.048004,-0.994835 v 4.254507 q 0,0.275167 0.02117,0.465668 0.02117,0.1905 0.08467,0.296333 0.0635,0.127001 0.211667,0.190501 0.148167,0.04233 0.444501,0.0635 l 2.921004,0.359834 q 0.910168,0.127 1.481669,0.3175 0.571501,0.1905 0.973669,0.592668 0.952501,0.994835 0.952501,3.534839 0,2.688171 -1.185335,3.767672 -0.529168,0.486835 -1.291169,0.719668 -0.740834,0.211667 -1.756836,0.275167 z"
id="path2740" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 84.655556,22.521326 q -0.698502,-0.08467 -2.455338,-0.232833 -0.973668,-0.04233 -1.566335,-0.04233 -0.762002,0 -1.439336,0.04233 v -3.280839 h 0.529167 q 1.73567,0 3.598339,0.232834 0.592668,0.08467 1.333503,0.232833 z"
id="path2715" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 90.222387,23.410328 h 4.23334 v 10.329349 h -4.23334 z"
id="path2746" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="M 86.391214,19.176988 H 98.308067 V 22.56366 H 86.391214 Z"
id="path2717" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 106.33024,23.685495 1.9685,-4.508507 h 4.23334 l -1.651,3.429005 -0.6985,1.439336 -1.33351,2.772837 -0.52916,1.100669 z"
id="path2750" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 103.59973,28.934836 -0.1905,-0.423334 -0.55033,-1.079501 -0.52917,-1.121835 q -0.0847,-0.148167 -0.21167,-0.444501 l -0.86783,-1.79917 -2.328342,-4.889507 h 4.318012 l 4.59317,9.779015 v 4.783674 h -4.23334 z"
id="path2719" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 118.92441,25.971498 h 0.508 q 0.67733,0 0.99483,-0.04233 0.33867,-0.0635 0.52917,-0.254 0.1905,-0.169334 0.23283,-0.486835 0.0635,-0.338667 0.0635,-0.994834 0,-0.656168 -0.0635,-0.973669 -0.0423,-0.338667 -0.23283,-0.529167 -0.1905,-0.169334 -0.52917,-0.211667 -0.33866,-0.0635 -0.99483,-0.0635 h -0.508 v -3.238505 h 1.75683 q 1.56634,0 2.54001,0.3175 0.99483,0.296334 1.50283,0.931335 0.48684,0.592668 0.65617,1.502836 0.16933,0.889001 0.16933,2.264837 0,1.312335 -0.16933,2.18017 -0.14817,0.867834 -0.61383,1.460502 -0.52917,0.677334 -1.52401,1.037168 -0.97366,0.338668 -2.56117,0.338668 h -1.75683 z"
id="path2754" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 113.80207,19.176988 h 4.23334 v 14.562689 h -4.23334 z"
id="path2721" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 127.4546,19.176988 h 4.23334 v 14.562689 h -4.23334 z"
id="path2723" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 139.56195,25.971498 h 0.508 q 0.67734,0 0.99484,-0.04233 0.33866,-0.0635 0.52916,-0.254 0.1905,-0.169334 0.23284,-0.486835 0.0635,-0.338667 0.0635,-0.994834 0,-0.656168 -0.0635,-0.973669 -0.0423,-0.338667 -0.23284,-0.529167 -0.1905,-0.169334 -0.52916,-0.211667 -0.33867,-0.0635 -0.99484,-0.0635 h -0.508 v -3.238505 h 1.75684 q 1.56633,0 2.54,0.3175 0.99484,0.296334 1.50284,0.931335 0.48683,0.592668 0.65616,1.502836 0.16934,0.889001 0.16934,2.264837 0,1.312335 -0.16934,2.18017 -0.14816,0.867834 -0.61383,1.460502 -0.52917,0.677334 -1.524,1.037168 -0.97367,0.338668 -2.56117,0.338668 h -1.75684 z"
id="path2760" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 134.43961,19.176988 h 4.23334 v 14.562689 h -4.23334 z"
id="path2725" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 153.21448,30.501172 h 5.37635 v 3.238505 h -5.37635 z"
id="path2768" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 153.21448,24.764996 h 4.38151 v 3.238506 h -4.38151 z"
id="path2766" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#ff2000;fill-opacity:1"
d="m 153.21448,19.176988 h 5.37635 v 3.238505 h -5.37635 z"
id="path2764" /><path
style="font-family:'Saira Stencil One';-inkscape-font-specification:'Saira Stencil One';fill:#8c441a;fill-opacity:1"
d="m 148.09214,19.176988 h 4.23334 v 14.562689 h -4.23334 z"
id="path2727" /></g><path
d="m 17.157261,11.267722 c 0.02821,-0.225786 0.04939,-0.451553 0.04939,-0.684389 0,-0.232826 -0.02107,-0.465666 -0.04939,-0.7055542 l 1.488721,-1.150055 c 0.134053,-0.105841 0.169334,-0.296333 0.08466,-0.451555 l -1.411108,-2.441223 c -0.08466,-0.155226 -0.275166,-0.218719 -0.43039,-0.155226 l -1.75683,0.705555 c -0.366888,-0.275166 -0.747887,-0.515056 -1.192389,-0.691443 l -0.261066,-1.869722 c -0.02822,-0.169333 -0.1764,-0.296332 -0.352775,-0.296332 h -2.822222 c -0.176401,0 -0.324554,0.127013 -0.352776,0.296332 l -0.2610673,1.869722 c -0.444501,0.176373 -0.825501,0.416277 -1.192388,0.691443 l -1.756835,-0.705555 c -0.155226,-0.06349 -0.345719,0 -0.430385,0.155226 l -1.411112,2.441223 c -0.09173,0.155226 -0.04939,0.34572 0.08467,0.451555 l 1.488722,1.150055 c -0.02822,0.2398932 -0.04938,0.4727232 -0.04938,0.7055542 0,0.232826 0.02107,0.458611 0.04938,0.684389 l -1.488722,1.171221 c -0.134053,0.10584 -0.1764,0.296333 -0.08467,0.451556 l 1.411112,2.44122 c 0.08466,0.155227 0.275165,0.211654 0.430385,0.155227 l 1.756835,-0.712611 c 0.366887,0.282222 0.747887,0.522112 1.192388,0.698501 l 0.2610673,1.86972 c 0.02821,0.169333 0.1764,0.296333 0.352776,0.296333 h 2.822222 c 0.176399,0 0.324554,-0.126987 0.352775,-0.296333 l 0.261066,-1.86972 c 0.444502,-0.183439 0.825501,-0.416279 1.192389,-0.698501 l 1.75683,0.712611 c 0.155227,0.05646 0.345723,0 0.43039,-0.155227 l 1.41111,-2.44122 c 0.08466,-0.155227 0.04939,-0.345721 -0.08466,-0.451556 z"
id="path458"
style="fill:none;fill-opacity:1;stroke:#8c441a;stroke-width:1.5875;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="csccccccccssccccccccsccccccccsscccccccc" /><path
style="fill:#ff2000;fill-opacity:1;stroke-width:0.829285"
d="M 10.29091,13.0712 14.594918,10.583335 10.29091,8.0954668 V 13.0712"
id="path1225" /></g></svg>

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,30 +0,0 @@
# About the new `pot` token
YouTube has implemented a new method to prevent downloaders and alternative clients from accessing
their videos. Now requests to YouTube's video servers require a `pot` URL parameter.
It is currently only required in the web player. The YTM and embedded player sends the token, too, but does not require it (this may change in the future).
The TV player does not use the token at all and is currently the best workaround. The only downside
is that the TV player does not return any video metadata like title and description text.
The first part of a video file (range: 0-1007959 bytes) can be downloaded without the token.
Requesting more of the file requires the pot token to be set, otherwise YouTube responds with a 403
error.
The pot token is base64-formatted and usually starts with a M
`MnToZ2brHmyo0ehfKtK_EWUq60dPYDXksNX_UsaniM_Uj6zbtiIZujCHY02hr7opxB_n3XHetJQCBV9cnNHovuhvDqrjfxsKR-sjn-eIxqv3qOZKphvyDpQzlYBnT2AXK41R-ti6iPonrvlvKIASNmYX2lhsEg==`
The token is generated from YouTubes Botguard script. The token is bound to the visitor data ID
used to fetch the player data.
This feature has been A/B-tested for a few weeks. During that time, refetching the player in case
of a 403 download error often made things work again. As of 08.08.2024 this new feature seems to be
stabilized and retrying requests does not work any more.
## Getting a `pot` token
You need a real browser environment to run YouTube's botguard and obtain a pot token. The Invidious project has created a script to
<https://github.com/iv-org/youtube-trusted-session-generator/tree/master>.
The script opens YouTube's embedded video player, starts playback and extracts the visitor data

12
postprocessor/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "rustypipe-postprocessor"
version = "0.1.0"
edition = "2021"
[dependencies]
thiserror = "1.0.40"
[dev-dependencies]
path_macro = "1.0.0"
once_cell = "1.12.0"
temp_testdir = "0.2.3"

75
postprocessor/src/crc.rs Normal file
View file

@ -0,0 +1,75 @@
// Ogg decoder and encoder written in Rust
//
// Original source: https://github.com/RustAudio/ogg
//
// Copyright (c) 2016-2017 est31 <MTest31@outlook.com>
// and contributors. All rights reserved.
// Redistribution or use only under the terms
// specified in the LICENSE file attached to this
// source distribution.
/*!
Implementation of the CRC algorithm with the
vorbis specific parameters and setup
*/
// Lookup table to enable bytewise CRC32 calculation
static CRC_LOOKUP_ARRAY: &[u32] = &lookup_array();
const fn get_tbl_elem(idx: u32) -> u32 {
let mut r: u32 = idx << 24;
let mut i = 0;
while i < 8 {
r = (r << 1) ^ (-(((r >> 31) & 1) as i32) as u32 & 0x04c11db7);
i += 1;
}
r
}
const fn lookup_array() -> [u32; 0x100] {
let mut lup_arr: [u32; 0x100] = [0; 0x100];
let mut i = 0;
while i < 0x100 {
lup_arr[i] = get_tbl_elem(i as u32);
i += 1;
}
lup_arr
}
pub fn crc32(array: &[u8]) -> u32 {
crc32_update(0, array)
}
pub fn crc32_update(cur: u32, array: &[u8]) -> u32 {
let mut ret: u32 = cur;
for av in array {
ret = (ret << 8) ^ CRC_LOOKUP_ARRAY[(*av as u32 ^ (ret >> 24)) as usize];
}
ret
}
#[test]
fn test_crc32() {
// Test page taken from real Ogg file
let test_arr = &[
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x74,
0xa3, 0x90, 0x5b, 0x00, 0x00, 0x00, 0x00,
// The spec requires us to zero out the CRC field
/*0x6d, 0x94, 0x4e, 0x3d,*/
0x00, 0x00, 0x00, 0x00, 0x01, 0x1e, 0x01, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x00, 0x00,
0x00, 0x00, 0x02, 0x44, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xb5, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0xb8, 0x01,
];
println!();
println!(
"CRC of \"==!\" calculated as 0x{:08x} (expected 0x9f858776)",
crc32(&[61, 61, 33])
);
println!(
"Test page CRC calculated as 0x{:08x} (expected 0x3d4e946d)",
crc32(test_arr)
);
assert_eq!(crc32(&[61, 61, 33]), 0x9f858776);
assert_eq!(crc32(test_arr), 0x3d4e946d);
assert_eq!(crc32(&test_arr[0..27]), 0x7b374db8);
}

73
postprocessor/src/lib.rs Normal file
View file

@ -0,0 +1,73 @@
#![allow(dead_code)]
pub mod ogg_from_webm;
mod crc;
mod ogg;
mod webm;
/// Error from the postprocessor
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum PostprocessingError {
/// File IO error
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("file format not recognized: {0}")]
InvalidFormat(&'static str),
#[error("no such element: expected {0:#x}, got {1:#x}")]
NoSuchElement(u32, u32),
#[error("bad element: {0}")]
BadElement(String),
#[error("invalid encoded length")]
InvalidLength,
#[error("invalid string")]
InvalidString,
#[error("unexpected element size")]
UnexpectedSize,
#[error("unsupported op: {0}")]
Unsupported(&'static str),
#[error("int conversion: {0}")]
Conversion(#[from] std::num::TryFromIntError),
}
pub(crate) type Result<T> = std::result::Result<T, PostprocessingError>;
#[cfg(test)]
pub(crate) mod tests {
use std::{
fs::File,
io::{BufReader, Read},
os::unix::prelude::MetadataExt,
path::{Path, PathBuf},
};
use once_cell::sync::Lazy;
use path_macro::path;
pub static TESTFILES: Lazy<PathBuf> = Lazy::new(|| {
path!(env!("CARGO_MANIFEST_DIR") / ".." / "testfiles")
.canonicalize()
.unwrap()
});
pub fn assert_files_eq<P: AsRef<Path>, P2: AsRef<Path>>(p1: P, p2: P2) {
let f1 = File::open(p1).unwrap();
let f2 = File::open(p2).unwrap();
let size = f1.metadata().unwrap().size();
assert_eq!(size, f2.metadata().unwrap().size(), "File sizes dont match");
let mut r1 = BufReader::new(f1);
let mut r2 = BufReader::new(f2);
for i in 0..size {
let mut b1 = [0; 1];
let mut b2 = [0; 1];
r1.read_exact(&mut b1).unwrap();
r2.read_exact(&mut b2).unwrap();
assert_eq!(b1[0], b2[0], "Byte {i} does not match");
}
}
}

131
postprocessor/src/ogg.rs Normal file
View file

@ -0,0 +1,131 @@
use crate::{crc, PostprocessingError, Result};
pub struct OggWriter {
sequence_count: u32,
stream_id: u32,
packet_flag: u8,
segment_table: [u8; 255],
segment_table_size: u8,
pub segment_table_next_timestamp: u64,
}
pub const FLAG_UNSET: u8 = 0x00;
pub const FLAG_FIRST: u8 = 0x02;
pub const FLAG_LAST: u8 = 0x04;
const HEADER_CHECKSUM_OFFSET: usize = 22;
const HEADER_SIZE: usize = 27;
pub const TIME_SCALE_NS: u64 = 1000000000;
pub const METADATA_VORBIS: [u8; 15] = [
0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
0x00, 0x00, 0x00, 0x00, // additional tags count (zero means no tags)
];
pub const METADATA_OPUS: [u8; 16] = [
0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
0x00, 0x00, 0x00, 0x00, // additional tags count (zero means no tags)
];
impl OggWriter {
pub fn new() -> Self {
Self {
sequence_count: 0,
stream_id: 1,
packet_flag: FLAG_FIRST,
segment_table: [0; 255],
segment_table_size: 0,
segment_table_next_timestamp: TIME_SCALE_NS,
}
}
pub fn make_packet_header(&mut self, gran_pos: u64, page: &[u8], immediate: bool) -> Vec<u8> {
let mut header = Vec::new();
let mut length = HEADER_SIZE;
header.extend_from_slice(b"OggS");
header.push(0); // version
header.push(self.packet_flag); // type
header.extend_from_slice(&gran_pos.to_le_bytes()); // granulate position
header.extend_from_slice(&self.stream_id.to_le_bytes()); // bitstream serial number
header.extend_from_slice(&self.sequence_count.to_le_bytes()); // page sequence number
self.sequence_count += 1;
header.extend_from_slice(&[0, 0, 0, 0]); // page checksum
header.push(self.segment_table_size);
header.extend_from_slice(&self.segment_table[0..self.segment_table_size as usize]);
length += self.segment_table_size as usize;
self.clear_segment_table();
let checksum = crc::crc32(&header[0..length]);
let checksum = crc::crc32_update(checksum, page);
Self::header_put_checksum(&mut header, checksum);
if immediate {
self.segment_table_next_timestamp -= TIME_SCALE_NS;
}
header
}
fn header_put_checksum(header: &mut Vec<u8>, checksum: u32) {
let cs_bts = checksum.to_le_bytes();
let to_fill = (HEADER_CHECKSUM_OFFSET + cs_bts.len()).saturating_sub(header.len());
for _ in 0..to_fill {
header.push(0);
}
header[HEADER_CHECKSUM_OFFSET..(HEADER_CHECKSUM_OFFSET + cs_bts.len())]
.copy_from_slice(&cs_bts[..]);
}
pub fn clear_segment_table(&mut self) {
self.segment_table_next_timestamp += TIME_SCALE_NS;
self.packet_flag = FLAG_UNSET;
self.segment_table_size = 0;
}
pub fn add_packet_segment(&mut self, size: u32) -> Result<bool> {
if size > 65025 {
return Err(PostprocessingError::Unsupported(
"page size cannot be larger than 65025",
));
}
let mut available =
(self.segment_table.len() as u32 - self.segment_table_size as u32) * 255;
let extra = (size % 255) == 0;
if extra {
// add a zero byte entry in the table
// required to indicate the sample size is multiple of 255
available -= 255;
}
// check if possible add the segment, without overflow the table
if available < size {
return Ok(false); // not enough space on the page
}
let mut seg = size;
while seg > 0 {
self.segment_table[self.segment_table_size as usize] = seg.min(255) as u8;
self.segment_table_size += 1;
seg = seg.saturating_sub(255);
}
if extra {
self.segment_table[self.segment_table_size as usize] = 0;
self.segment_table_size += 1;
}
Ok(true)
}
pub fn set_packet_flag(&mut self, flag: u8) {
self.packet_flag = flag;
}
}

View file

@ -0,0 +1,179 @@
use std::{
fs::File,
io::{BufWriter, Read, Write},
path::Path,
};
use crate::{
ogg::{OggWriter, FLAG_LAST, METADATA_OPUS, METADATA_VORBIS, TIME_SCALE_NS},
webm::{TrackKind, WebmReader},
PostprocessingError,
};
pub fn test(mut source: impl Read) -> Result<bool, PostprocessingError> {
let mut buf = [0; 4];
source.read_exact(&mut buf)?;
match u32::from_be_bytes(buf) {
0x1a45dfa3 => Ok(true), // webm/mkv
0x4f676753 => Ok(false), // ogg
_ => Err(PostprocessingError::InvalidFormat("unknown magic number")),
}
}
pub fn process<P: AsRef<Path>, P2: AsRef<Path>>(
source: P,
dest: P2,
) -> Result<(), PostprocessingError> {
let mut webm = WebmReader::new(File::open(source)?)?;
webm.parse()?;
if !webm.get_next_segment()? {
return Err(PostprocessingError::InvalidFormat("no segment"));
}
let mut ogg = OggWriter::new();
let mut output = BufWriter::new(File::create(dest)?);
// Select track
let track = match &webm.tracks {
Some(tracks) => {
let track_id = tracks
.iter()
.enumerate()
.find(|(_, track)| {
track.kind == TrackKind::Audio
&& (track.codec_id == "A_OPUS" || track.codec_id == "A_VORBIS")
})
.ok_or(PostprocessingError::InvalidFormat("no audio tracks"))?
.0;
webm.select_track(track_id).unwrap().clone()
}
None => return Err(PostprocessingError::InvalidFormat("no tracks")),
};
// Get sample rate
let res = get_sample_freq_from_track(&track.b_metadata);
// Create packet with codec init data
if !track.codec_private.is_empty() {
ogg.add_packet_segment(track.codec_private.len().try_into()?)?;
let header = ogg.make_packet_header(0, &track.codec_private, true);
output.write_all(&header)?;
output.write_all(&track.codec_private)?;
}
// Create packet with metadata
let metadata = match track.codec_id.as_str() {
"A_OPUS" => METADATA_OPUS.as_slice(),
"A_VORBIS" => METADATA_VORBIS.as_slice(),
_ => unreachable!(),
};
ogg.add_packet_segment(metadata.len().try_into()?)?;
let header = ogg.make_packet_header(0, metadata, true);
output.write_all(&header)?;
output.write_all(metadata)?;
// Calculate amount of packets
let mut page = Vec::new();
let mut webm_block = None;
while !webm.is_done() {
let block = match webm_block {
Some(block) => {
webm_block = None;
Some(block)
}
None => webm.get_next_block()?,
};
if let Some(block) = block {
let timestamp = block.absolute_time_code_ns + track.codec_delay;
let added = if timestamp >= ogg.segment_table_next_timestamp {
false
} else {
ogg.add_packet_segment(block.data_size.try_into()?)?
};
if added {
webm.get_block_data(&block, &mut page)?;
continue;
}
}
let mut elapsed_ns = track.codec_delay;
match block {
Some(block) => {
elapsed_ns += block.absolute_time_code_ns;
}
None => {
// TODO: move to ogg
ogg.set_packet_flag(FLAG_LAST);
elapsed_ns += webm.webm_block_last_timecode();
if track.default_duration > 0 {
elapsed_ns += track.default_duration;
} else {
elapsed_ns += webm.webm_block_near_duration();
}
}
}
// get the sample count in the page
let sample_count: f64 = (elapsed_ns as f64 / TIME_SCALE_NS as f64) * res as f64;
let sample_count = sample_count.ceil() as u64;
let header = ogg.make_packet_header(sample_count, &page, false);
// dump data
output.write_all(&header)?;
output.write_all(&page)?;
page = Vec::new();
webm_block = block;
}
output.flush()?;
Ok(())
}
fn get_sample_freq_from_track(b_metadata: &[u8]) -> f32 {
let mut i = 0;
while i < b_metadata.len().saturating_sub(5) {
let id_bts: [u8; 2] = b_metadata[i..i + 2].try_into().unwrap();
let id = u16::from_be_bytes(id_bts);
if id == 0xB584 {
let freq_bts: [u8; 4] = b_metadata[i + 2..i + 6].try_into().unwrap();
return f32::from_be_bytes(freq_bts);
}
i += 2;
}
0.0
}
#[cfg(test)]
mod tests {
use path_macro::path;
use crate::tests::{assert_files_eq, TESTFILES};
use super::*;
#[test]
fn t_test() {
let path = path!(*TESTFILES / "postprocessor" / "audio1.webm");
let mut file = File::open(&path).unwrap();
assert!(test(&mut file).unwrap());
}
#[test]
fn t_process() {
let temp = temp_testdir::TempDir::default();
let source = path!(*TESTFILES / "postprocessor" / "audio1.webm");
let dest = path!(temp / "audio1.ogg");
let expect = path!(*TESTFILES / "postprocessor" / "conv" / "audio1.ogg");
process(&source, &dest).unwrap();
assert_files_eq(&dest, &expect);
}
}

736
postprocessor/src/webm.rs Normal file
View file

@ -0,0 +1,736 @@
use std::{
fs::File,
io::{BufReader, Read, Seek, SeekFrom},
os::unix::prelude::MetadataExt,
};
use crate::{PostprocessingError, Result};
#[derive(Debug)]
pub struct WebmReader {
stream: BufReader<File>,
len: u64,
segment: Option<Segment>,
cluster: Option<Cluster>,
pub tracks: Option<Vec<WebmTrack>>,
selected_track: Option<usize>,
done: bool,
first_segment: bool,
webm_block_near_duration: u64,
webm_block_last_timecode: u64,
}
#[derive(Debug, Copy, Clone)]
struct Element {
typ: u32,
offset: u64,
content_size: u64,
size: u64,
}
#[derive(Debug, Copy, Clone)]
pub struct Info {
pub timecode_scale: u64,
pub duration: u64,
}
#[derive(Debug, Clone)]
pub struct WebmTrack {
pub track_number: u64,
track_type: u32,
pub codec_id: String,
pub codec_private: Vec<u8>,
pub b_metadata: Vec<u8>,
pub kind: TrackKind,
pub default_duration: u64,
pub codec_delay: u64,
pub seek_pre_roll: u64,
}
#[derive(Debug, Clone)]
pub struct Segment {
pub info: Option<Info>,
tracks: Option<Vec<WebmTrack>>,
current_cluster: Option<Element>,
rf: Element,
first_cluster_in_segment: bool,
}
impl Segment {
fn new(rf: Element) -> Segment {
Segment {
info: None,
tracks: None,
current_cluster: None,
rf,
first_cluster_in_segment: true,
}
}
}
#[derive(Debug, Copy, Clone)]
pub struct SimpleBlock {
pub created_from_block: bool,
pub track_number: u64,
pub relative_time_code: u16,
pub absolute_time_code_ns: u64,
pub flags: u8,
pub offset: u64,
pub data_size: u64,
rf: Element,
}
impl SimpleBlock {
fn new(rf: Element) -> Self {
Self {
created_from_block: false,
track_number: 0,
relative_time_code: 0,
absolute_time_code_ns: 0,
flags: 0,
offset: 0,
data_size: 0,
rf,
}
}
pub fn is_keyframe(&self) -> bool {
(self.flags & 0x80) == 0x80
}
}
#[derive(Debug, Copy, Clone)]
pub struct Cluster {
rf: Element,
current_simple_block: Option<SimpleBlock>,
current_block_group: Option<Element>,
timecode: u64,
}
impl Cluster {
fn new(rf: Element) -> Self {
Self {
rf,
current_simple_block: None,
current_block_group: None,
timecode: 0,
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum TrackKind {
Audio,
Video,
Other,
}
const ID_EMBL: u32 = 0x0A45DFA3;
const ID_EMBL_READ_VERSION: u32 = 0x02F7;
const ID_EMBL_DOC_TYPE: u32 = 0x0282;
const ID_EMBL_DOC_TYPE_READ_VERSION: u32 = 0x0285;
const ID_SEGMENT: u32 = 0x08538067;
const ID_INFO: u32 = 0x0549A966;
const ID_TIMECODE_SCALE: u32 = 0x0AD7B1;
const ID_DURATION: u32 = 0x489;
const ID_TRACKS: u32 = 0x0654AE6B;
const ID_TRACK_ENTRY: u32 = 0x2E;
const ID_TRACK_NUMBER: u32 = 0x57;
const ID_TRACK_TYPE: u32 = 0x03;
const ID_CODEC_ID: u32 = 0x06;
const ID_CODEC_PRIVATE: u32 = 0x23A2;
const ID_VIDEO: u32 = 0x60;
const ID_AUDIO: u32 = 0x61;
const ID_DEFAULT_DURATION: u32 = 0x3E383;
const ID_FLAG_LACING: u32 = 0x1C;
const ID_CODEC_DELAY: u32 = 0x16AA;
const ID_SEEK_PRE_ROLL: u32 = 0x16BB;
const ID_CLUSTER: u32 = 0x0F43B675;
const ID_TIMECODE: u32 = 0x67;
const ID_SIMPLE_BLOCK: u32 = 0x23;
const ID_BLOCK: u32 = 0x21;
const ID_GROUP_BLOCK: u32 = 0x20;
impl WebmReader {
pub fn new(file: File) -> Result<Self> {
let md = file.metadata()?;
Ok(Self {
stream: BufReader::new(file),
len: md.size(),
segment: None,
cluster: None,
tracks: None,
selected_track: None,
done: false,
first_segment: false,
webm_block_near_duration: 0,
webm_block_last_timecode: 0,
})
}
/// Make sure the parser did not go beyond the current element and
/// skip to the start if the next element.
fn ensure(&mut self, rf: &Element) -> Result<()> {
let pos = self.stream.stream_position()?;
let elem_end = rf.offset + rf.size;
if pos > elem_end {
return Err(PostprocessingError::Io(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
format!(
"parser go beyond limits of the Element. type={} offset={} size={} position={}",
rf.typ, rf.offset, rf.size, pos,
),
)));
}
self.stream.seek(SeekFrom::Start(elem_end))?;
Ok(())
}
fn read_byte(&mut self) -> Result<u8> {
let mut bt = [0; 1];
self.stream.read_exact(&mut bt)?;
Ok(bt[0])
}
fn read_u16(&mut self) -> Result<u16> {
let mut bt = [0; 2];
self.stream.read_exact(&mut bt)?;
Ok(u16::from_be_bytes(bt))
}
fn read_number(&mut self, parent: &Element) -> Result<u64> {
let mut value: u64 = 0;
for _ in 0..parent.content_size {
let rd = self.read_byte()?;
value = (value << 8) | u64::from(rd);
}
Ok(value)
}
fn read_string(&mut self, parent: &Element) -> Result<String> {
String::from_utf8(self.read_blob(parent)?).map_err(|_| PostprocessingError::InvalidString)
}
fn read_blob(&mut self, parent: &Element) -> Result<Vec<u8>> {
let length = parent.content_size as usize;
let mut buf = vec![0u8; length];
self.stream.read_exact(&mut buf)?;
Ok(buf)
}
fn read_encoded_number(&mut self) -> Result<u64> {
let mut value = u64::from(self.read_byte()?);
if value > 0 {
let mut mask: u64 = 0x80;
for size in 1..9 {
if (value & mask) == mask {
mask = 0xff;
mask >>= size;
let mut number = value & mask;
for _ in 1..size {
value = u64::from(self.read_byte()?);
number = (number << 8) | value;
}
return Ok(number);
}
mask >>= 1;
}
}
Err(PostprocessingError::InvalidLength)
}
fn read_element(&mut self) -> Result<Element> {
let offset = self.stream.stream_position()?;
let typ = self.read_encoded_number()? as u32;
let content_size = self.read_encoded_number()?;
let size = content_size + self.stream.stream_position()? - offset;
Ok(Element {
typ,
offset,
content_size,
size,
})
}
fn read_element_expected(&mut self, expected: u32) -> Result<Element> {
let elem = self.read_element()?;
if expected != 0 && elem.typ != expected {
return Err(PostprocessingError::NoSuchElement(expected, elem.typ));
}
Ok(elem)
}
fn until_element(&mut self, rf: Option<&Element>, expected: &[u32]) -> Result<Option<Element>> {
loop {
let brk = match rf {
Some(rf) => self.stream.stream_position()? >= (rf.offset + rf.size),
None => self.stream.stream_position()? >= self.len,
};
if brk {
break;
}
let elem = self.read_element()?;
if expected.is_empty() {
return Ok(Some(elem));
}
for t in expected {
if &elem.typ == t {
return Ok(Some(elem));
}
}
self.ensure(&elem)?;
}
Ok(None)
}
fn read_ebml(
&mut self,
rf: &Element,
min_read_version: u64,
min_doc_type_version: u64,
) -> Result<bool> {
let elem_v = self.until_element(Some(rf), &[ID_EMBL_READ_VERSION])?;
match elem_v {
Some(elem_v) => {
if self.read_number(&elem_v)? > min_read_version {
return Ok(false);
}
let elem_t = self.until_element(Some(rf), &[ID_EMBL_DOC_TYPE])?;
match elem_t {
Some(elem_t) => {
if self.read_string(&elem_t)? != "webm" {
return Ok(false);
}
let elem_tv =
self.until_element(Some(rf), &[ID_EMBL_DOC_TYPE_READ_VERSION])?;
match elem_tv {
Some(elem_tv) => {
Ok(self.read_number(&elem_tv)? <= min_doc_type_version)
}
None => Ok(false),
}
}
None => Ok(false),
}
}
None => Ok(false),
}
}
fn read_info(&mut self, rf: &Element) -> Result<Info> {
let mut info = Info {
timecode_scale: 0,
duration: 0,
};
while let Some(elem) = self.until_element(Some(rf), &[ID_TIMECODE_SCALE, ID_DURATION])? {
match elem.typ {
ID_TIMECODE_SCALE => {
info.timecode_scale = self.read_number(&elem)?;
}
ID_DURATION => {
info.duration = self.read_number(&elem)?;
}
_ => {}
}
self.ensure(&elem)?;
}
if info.timecode_scale == 0 {
return Err(PostprocessingError::BadElement(
"Element Timecode not found".to_owned(),
));
}
Ok(info)
}
fn read_segment(
&mut self,
rf: Element,
track_lacing_expected: u64,
metadata_expected: bool,
) -> Result<Segment> {
let mut seg = Segment::new(rf);
while let Some(elem) = self.until_element(Some(&rf), &[ID_INFO, ID_TRACKS, ID_CLUSTER])? {
match elem.typ {
ID_CLUSTER => {
seg.current_cluster = Some(elem);
break;
}
ID_INFO => {
seg.info = Some(self.read_info(&elem)?);
self.ensure(&elem)?;
}
ID_TRACKS => {
seg.tracks = Some(self.read_tracks(&elem, track_lacing_expected)?);
self.ensure(&elem)?;
}
_ => {}
}
}
if metadata_expected && (seg.info.is_none() || seg.tracks.is_none()) {
return Err(PostprocessingError::BadElement(format!(
"Cluster element found without Info and/or Tracks element at position {}",
rf.offset
)));
}
Ok(seg)
}
fn read_tracks(&mut self, rf: &Element, lacing_expected: u64) -> Result<Vec<WebmTrack>> {
let mut tracks = Vec::new();
while let Some(elem_te) = self.until_element(Some(rf), &[ID_TRACK_ENTRY])? {
let mut entry = WebmTrack {
track_number: 0,
track_type: 0,
codec_id: String::new(),
codec_private: Vec::new(),
b_metadata: Vec::new(),
kind: TrackKind::Other,
default_duration: 0,
codec_delay: 0,
seek_pre_roll: 0,
};
let mut drop = false;
while let Some(elem) = self.until_element(Some(&elem_te), &[])? {
match elem.typ {
ID_TRACK_NUMBER => {
entry.track_number = self.read_number(&elem)?;
}
ID_TRACK_TYPE => {
entry.track_type = self.read_number(&elem)? as u32;
}
ID_CODEC_ID => {
entry.codec_id = self.read_string(&elem)?;
}
ID_CODEC_PRIVATE => {
entry.codec_private = self.read_blob(&elem)?;
}
ID_AUDIO | ID_VIDEO => {
entry.b_metadata = self.read_blob(&elem)?;
}
ID_DEFAULT_DURATION => {
entry.default_duration = self.read_number(&elem)?;
}
ID_FLAG_LACING => {
drop = self.read_number(&elem)? != lacing_expected;
}
ID_CODEC_DELAY => {
entry.codec_delay = self.read_number(&elem)?;
}
ID_SEEK_PRE_ROLL => {
entry.seek_pre_roll = self.read_number(&elem)?;
}
_ => {}
}
self.ensure(&elem)?;
}
entry.kind = match entry.track_type {
1 => TrackKind::Video,
2 => TrackKind::Audio,
_ => TrackKind::Other,
};
if !drop {
tracks.push(entry);
}
self.ensure(&elem_te)?;
}
Ok(tracks)
}
fn read_simple_block(&mut self, rf: Element) -> Result<SimpleBlock> {
let mut sb = SimpleBlock::new(rf);
sb.track_number = self.read_encoded_number()?;
sb.relative_time_code = self.read_u16()?;
sb.flags = self.read_byte()?;
let pos = self.stream.stream_position()?;
sb.data_size = (rf.offset + rf.size)
.checked_sub(pos)
.ok_or(PostprocessingError::UnexpectedSize)?;
sb.offset = pos;
sb.created_from_block = rf.typ == ID_BLOCK;
Ok(sb)
}
fn read_cluster(&mut self, rf: Element) -> Result<Cluster> {
let elem = self.until_element(Some(&rf), &[ID_TIMECODE])?;
let mut cl = Cluster::new(rf);
match elem {
Some(elem) => {
cl.timecode = self.read_number(&elem)?;
}
None => {
return Err(PostprocessingError::BadElement(format!(
"Cluster at {} without Timecode element",
rf.offset
)))
}
}
Ok(cl)
}
fn inside_cluster_bounds(&mut self, cluster: &Cluster) -> Result<bool> {
Ok(self.stream.stream_position()? >= (cluster.rf.offset + cluster.rf.size))
}
pub fn get_next_cluster(&mut self) -> Result<Option<Cluster>> {
if self.done {
return Ok(None);
}
let (rf, cc) = if let Some(segment) = &mut self.segment {
if let Some(current_cluster) = segment.current_cluster {
if segment.first_cluster_in_segment {
segment.first_cluster_in_segment = false;
return Ok(Some(self.read_cluster(current_cluster)?));
}
(segment.rf, current_cluster)
} else {
return Ok(None);
}
} else {
return Ok(None);
};
self.ensure(&cc)?;
let elem = self.until_element(Some(&rf), &[ID_CLUSTER])?;
match elem {
Some(elem) => {
self.segment.as_mut().unwrap().current_cluster = Some(elem);
Ok(Some(self.read_cluster(elem)?))
}
None => Ok(None),
}
}
pub fn get_next_simple_block(&mut self, cluster: &mut Cluster) -> Result<Option<SimpleBlock>> {
if self.inside_cluster_bounds(cluster)? {
return Ok(None);
}
if let Some(current_block_group) = &cluster.current_block_group {
self.ensure(current_block_group)?;
cluster.current_block_group = None;
cluster.current_simple_block = None;
} else if let Some(current_simple_block) = &cluster.current_simple_block {
self.ensure(&current_simple_block.rf)?;
}
while !self.inside_cluster_bounds(cluster)? {
let elem = self.until_element(Some(&cluster.rf), &[ID_SIMPLE_BLOCK, ID_GROUP_BLOCK])?;
let nelem = match elem {
Some(elem) => {
if elem.typ == ID_GROUP_BLOCK {
cluster.current_block_group = Some(elem);
let block_elem = self.until_element(Some(&elem), &[ID_BLOCK])?;
match block_elem {
Some(block_elem) => block_elem,
None => {
self.ensure(&elem)?;
cluster.current_block_group = None;
continue;
}
}
} else {
elem
}
}
None => return Ok(None),
};
let mut csb = self.read_simple_block(nelem)?;
if self
.selected_track()
.map(|st| st.track_number == csb.track_number)
.unwrap_or_default()
{
csb.absolute_time_code_ns = (u64::from(csb.relative_time_code) + cluster.timecode)
* self
.segment
.as_ref()
.and_then(|seg| seg.info)
.map(|inf| inf.timecode_scale)
.unwrap_or(1);
cluster.current_simple_block = Some(csb);
return Ok(Some(csb));
}
cluster.current_simple_block = Some(csb);
self.ensure(&nelem)?;
}
Ok(None)
}
pub fn get_next_block(&mut self) -> Result<Option<SimpleBlock>> {
if self.segment.is_none() && !self.get_next_segment()? {
return Ok(None);
}
if self.cluster.is_none() {
self.cluster = self.get_next_cluster()?;
if self.cluster.is_none() {
self.segment = None;
return self.get_next_block();
}
}
let mut c = self.cluster.unwrap();
let res = self.get_next_simple_block(&mut c)?;
self.cluster = Some(c);
match res {
Some(res) => {
self.webm_block_near_duration =
res.absolute_time_code_ns - self.webm_block_last_timecode;
self.webm_block_last_timecode = res.absolute_time_code_ns;
Ok(Some(res))
}
None => {
self.cluster = None;
self.get_next_block()
}
}
}
pub fn selected_track(&self) -> Option<&WebmTrack> {
if let (Some(tracks), Some(st)) = (&self.tracks, self.selected_track) {
return tracks.get(st);
}
None
}
pub fn select_track(&mut self, index: usize) -> Option<&WebmTrack> {
if let Some(tracks) = &self.tracks {
match tracks.get(index) {
Some(track) => {
self.selected_track = Some(index);
Some(track)
}
None => None,
}
} else {
None
}
}
pub fn parse(&mut self) -> Result<()> {
let elem_ebml = self.read_element_expected(ID_EMBL)?;
if !self.read_ebml(&elem_ebml, 1, 2)? {
return Err(PostprocessingError::InvalidFormat(
"Unsupported EBML data (WebM)",
));
}
self.ensure(&elem_ebml)?;
let elem_seg = self.until_element(None, &[ID_SEGMENT])?;
match elem_seg {
Some(elem_seg) => {
let seg = self.read_segment(elem_seg, 0, true)?;
// TODO: avoid this clone
self.tracks = seg.tracks.clone();
self.segment = Some(seg);
self.selected_track = None;
self.done = false;
self.first_segment = true;
Ok(())
}
None => Err(PostprocessingError::InvalidFormat(
"Fragment element not found",
)),
}
}
pub fn get_next_segment(&mut self) -> Result<bool> {
if self.done {
return Ok(false);
}
if let Some(segment) = &self.segment {
if self.first_segment {
self.first_segment = false;
return Ok(true);
}
let srf = segment.rf;
self.ensure(&srf)?;
}
let elem = self.until_element(None, &[ID_SEGMENT])?;
match elem {
Some(elem) => {
self.segment = Some(self.read_segment(elem, 0, false)?);
Ok(true)
}
None => {
self.done = true;
Ok(false)
}
}
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn webm_block_near_duration(&self) -> u64 {
self.webm_block_near_duration
}
pub fn webm_block_last_timecode(&self) -> u64 {
self.webm_block_last_timecode
}
pub fn get_block_data(&mut self, block: &SimpleBlock, bytes: &mut Vec<u8>) -> Result<()> {
let old_pos = self.stream.stream_position()?;
self.stream.seek(SeekFrom::Start(block.offset))?;
for _ in 0..block.data_size {
let mut bt = [0; 1];
self.stream.read_exact(&mut bt)?;
bytes.push(bt[0]);
}
self.stream.seek(SeekFrom::Start(old_pos))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use path_macro::path;
use crate::tests::TESTFILES;
use super::*;
#[test]
fn read() {
let path = path!(*TESTFILES / "postprocessor" / "audio1.webm");
let file = File::open(&path).unwrap();
let mut reader = WebmReader::new(file).unwrap();
reader.parse().unwrap();
reader.get_next_segment().unwrap();
// TODO: check types
reader.select_track(0).unwrap();
dbg!(&reader);
}
}

View file

@ -1,11 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:best-practices", ":preserveSemverRanges"],
"semanticCommits": "enabled",
"automerge": true,
"automergeStrategy": "squash",
"osvVulnerabilityAlerts": true,
"labels": ["dependency-upgrade"],
"enabledManagers": ["cargo"],
"prHourlyLimit": 5
}

View file

@ -16,12 +16,11 @@
//! the cache as a JSON file. //! the cache as a JSON file.
use std::{ use std::{
fs::File, fs,
io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use tracing::error; use log::error;
pub(crate) const DEFAULT_CACHE_FILE: &str = "rustypipe_cache.json"; pub(crate) const DEFAULT_CACHE_FILE: &str = "rustypipe_cache.json";
@ -69,21 +68,7 @@ impl Default for FileStorage {
impl CacheStorage for FileStorage { impl CacheStorage for FileStorage {
fn write(&self, data: &str) { fn write(&self, data: &str) {
fn _write(path: &Path, data: &str) -> Result<(), std::io::Error> { fs::write(&self.path, data).unwrap_or_else(|e| {
let mut f = File::create(path)?;
// Set cache file permissions to 0600 on Unix-based systems
#[cfg(target_family = "unix")]
{
use std::os::unix::fs::PermissionsExt;
let metadata = f.metadata()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(path, permissions)?;
}
f.write_all(data.as_bytes())
}
_write(&self.path, data).unwrap_or_else(|e| {
error!( error!(
"Could not write cache to file `{}`. Error: {}", "Could not write cache to file `{}`. Error: {}",
self.path.to_string_lossy(), self.path.to_string_lossy(),
@ -97,7 +82,7 @@ impl CacheStorage for FileStorage {
return None; return None;
} }
match std::fs::read_to_string(&self.path) { match fs::read_to_string(&self.path) {
Ok(data) => Some(data), Ok(data) => Some(data),
Err(e) => { Err(e) => {
error!( error!(

View file

@ -1,28 +1,23 @@
use std::fmt::Debug;
use serde::Serialize; use serde::Serialize;
use time::OffsetDateTime;
use url::Url; use url::Url;
use crate::{ use crate::{
client::response::YouTubeListItem,
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{
paginator::{ContinuationEndpoint, Paginator}, paginator::{ContinuationEndpoint, Paginator},
Channel, ChannelInfo, PlaylistItem, Verification, VideoItem, Channel, ChannelInfo, PlaylistItem, VideoItem, YouTubeItem,
}, },
param::{ChannelOrder, ChannelVideoTab, Language}, param::{ChannelOrder, ChannelVideoTab, Language},
serializer::{text::TextComponent, MapResult}, serializer::MapResult,
util::{self, timeago, ProtoBuilder}, util::{self, ProtoBuilder},
}; };
use super::{ use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QChannel<'a> { struct QChannel<'a> {
context: YTContext<'a>,
browse_id: &'a str, browse_id: &'a str,
params: ChannelTab, params: ChannelTab,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -39,6 +34,8 @@ enum ChannelTab {
Live, Live,
#[serde(rename = "EglwbGF5bGlzdHMgAQ%3D%3D")] #[serde(rename = "EglwbGF5bGlzdHMgAQ%3D%3D")]
Playlists, Playlists,
#[serde(rename = "EgVhYm91dPIGBAoCEgA%3D")]
Info,
#[serde(rename = "EgZzZWFyY2jyBgQKAloA")] #[serde(rename = "EgZzZWFyY2jyBgQKAloA")]
Search, Search,
} }
@ -62,7 +59,9 @@ impl RustyPipeQuery {
operation: &str, operation: &str,
) -> Result<Channel<Paginator<VideoItem>>, Error> { ) -> Result<Channel<Paginator<VideoItem>>, Error> {
let channel_id = channel_id.as_ref(); let channel_id = channel_id.as_ref();
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QChannel { let request_body = QChannel {
context,
browse_id: channel_id, browse_id: channel_id,
params, params,
query, query,
@ -79,8 +78,7 @@ impl RustyPipeQuery {
} }
/// Get the videos from a YouTube channel /// Get the videos from a YouTube channel
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_videos<S: AsRef<str>>(
pub async fn channel_videos<S: AsRef<str> + Debug>(
&self, &self,
channel_id: S, channel_id: S,
) -> Result<Channel<Paginator<VideoItem>>, Error> { ) -> Result<Channel<Paginator<VideoItem>>, Error> {
@ -91,8 +89,7 @@ impl RustyPipeQuery {
/// Get a ordered list of videos from a YouTube channel /// Get a ordered list of videos from a YouTube channel
/// ///
/// This function does not return channel metadata. /// This function does not return channel metadata.
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_videos_order<S: AsRef<str>>(
pub async fn channel_videos_order<S: AsRef<str> + Debug>(
&self, &self,
channel_id: S, channel_id: S,
order: ChannelOrder, order: ChannelOrder,
@ -102,8 +99,7 @@ impl RustyPipeQuery {
} }
/// Get the videos of the given tab (Shorts, Livestreams) from a YouTube channel /// Get the videos of the given tab (Shorts, Livestreams) from a YouTube channel
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_videos_tab<S: AsRef<str>>(
pub async fn channel_videos_tab<S: AsRef<str> + Debug>(
&self, &self,
channel_id: S, channel_id: S,
tab: ChannelVideoTab, tab: ChannelVideoTab,
@ -115,24 +111,27 @@ impl RustyPipeQuery {
/// Get a ordered list of videos from the given tab (Shorts, Livestreams) of a YouTube channel /// Get a ordered list of videos from the given tab (Shorts, Livestreams) of a YouTube channel
/// ///
/// This function does not return channel metadata. /// This function does not return channel metadata.
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_videos_tab_order<S: AsRef<str>>(
pub async fn channel_videos_tab_order<S: AsRef<str> + Debug>(
&self, &self,
channel_id: S, channel_id: S,
tab: ChannelVideoTab, tab: ChannelVideoTab,
order: ChannelOrder, order: ChannelOrder,
) -> Result<Paginator<VideoItem>, Error> { ) -> Result<Paginator<VideoItem>, Error> {
let visitor_data = match tab {
ChannelVideoTab::Shorts => Some(self.get_visitor_data().await?),
_ => None,
};
self.continuation( self.continuation(
order_ctoken(channel_id.as_ref(), tab, order, &random_target()), order_ctoken(channel_id.as_ref(), tab, order),
ContinuationEndpoint::Browse, ContinuationEndpoint::Browse,
None, visitor_data.as_deref(),
) )
.await .await
} }
/// Search the videos of a channel /// Search the videos of a channel
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_search<S: AsRef<str>, S2: AsRef<str>>(
pub async fn channel_search<S: AsRef<str> + Debug, S2: AsRef<str> + Debug>(
&self, &self,
channel_id: S, channel_id: S,
query: S2, query: S2,
@ -147,13 +146,14 @@ impl RustyPipeQuery {
} }
/// Get the playlists of a channel /// Get the playlists of a channel
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_playlists<S: AsRef<str>>(
pub async fn channel_playlists<S: AsRef<str> + Debug>(
&self, &self,
channel_id: S, channel_id: S,
) -> Result<Channel<Paginator<PlaylistItem>>, Error> { ) -> Result<Channel<Paginator<PlaylistItem>>, Error> {
let channel_id = channel_id.as_ref(); let channel_id = channel_id.as_ref();
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QChannel { let request_body = QChannel {
context,
browse_id: channel_id, browse_id: channel_id,
params: ChannelTab::Playlists, params: ChannelTab::Playlists,
query: None, query: None,
@ -170,26 +170,25 @@ impl RustyPipeQuery {
} }
/// Get additional metadata from the *About* tab of a channel /// Get additional metadata from the *About* tab of a channel
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_info<S: AsRef<str>>(
pub async fn channel_info<S: AsRef<str> + Debug>(
&self, &self,
channel_id: S, channel_id: S,
) -> Result<ChannelInfo, Error> { ) -> Result<Channel<ChannelInfo>, Error> {
let channel_id = channel_id.as_ref(); let channel_id = channel_id.as_ref();
let request_body = QContinuation { let context = self.get_context(ClientType::Desktop, true, None).await;
continuation: &channel_info_ctoken(channel_id, &random_target()), let request_body = QChannel {
context,
browse_id: channel_id,
params: ChannelTab::Info,
query: None,
}; };
self.execute_request_ctx::<response::ChannelAbout, _, _>( self.execute_request::<response::Channel, _, _>(
ClientType::Desktop, ClientType::Desktop,
"channel_info", "channel_info",
channel_id, channel_id,
"browse", "browse",
&request_body, &request_body,
MapRespOptions {
unlocalized: true,
..Default::default()
},
) )
.await .await
} }
@ -198,28 +197,27 @@ impl RustyPipeQuery {
impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel { impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> { ) -> Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> {
let content = map_channel_content(ctx.id, self.contents, self.alerts)?; let content = map_channel_content(id, self.contents, self.alerts)?;
let visitor_data = self
.response_context
.visitor_data
.or_else(|| ctx.visitor_data.map(str::to_owned));
let channel_data = map_channel( let channel_data = map_channel(
MapChannelData { MapChannelData {
header: self.header, header: self.header,
metadata: self.metadata, metadata: self.metadata,
microformat: self.microformat, microformat: self.microformat,
visitor_data: visitor_data.clone(), visitor_data: self.response_context.visitor_data.clone(),
has_shorts: content.has_shorts, has_shorts: content.has_shorts,
has_live: content.has_live, has_live: content.has_live,
}, },
ctx, id,
lang,
)?; )?;
let mut mapper = response::YouTubeListMapper::<VideoItem>::with_channel( let mut mapper = response::YouTubeListMapper::<VideoItem>::with_channel(
ctx.lang, lang,
&channel_data.c, &channel_data.c,
channel_data.warnings, channel_data.warnings,
); );
@ -228,9 +226,8 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
None, None,
mapper.items, mapper.items,
mapper.ctoken, mapper.ctoken,
visitor_data, self.response_context.visitor_data,
ContinuationEndpoint::Browse, crate::model::paginator::ContinuationEndpoint::Browse,
false,
); );
Ok(MapResult { Ok(MapResult {
@ -243,28 +240,27 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel { impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Channel<Paginator<PlaylistItem>>>, ExtractionError> { ) -> Result<MapResult<Channel<Paginator<PlaylistItem>>>, ExtractionError> {
let content = map_channel_content(ctx.id, self.contents, self.alerts)?; let content = map_channel_content(id, self.contents, self.alerts)?;
let visitor_data = self
.response_context
.visitor_data
.or_else(|| ctx.visitor_data.map(str::to_owned));
let channel_data = map_channel( let channel_data = map_channel(
MapChannelData { MapChannelData {
header: self.header, header: self.header,
metadata: self.metadata, metadata: self.metadata,
microformat: self.microformat, microformat: self.microformat,
visitor_data, visitor_data: self.response_context.visitor_data,
has_shorts: content.has_shorts, has_shorts: content.has_shorts,
has_live: content.has_live, has_live: content.has_live,
}, },
ctx, id,
lang,
)?; )?;
let mut mapper = response::YouTubeListMapper::<PlaylistItem>::with_channel( let mut mapper = response::YouTubeListMapper::<PlaylistItem>::with_channel(
ctx.lang, lang,
&channel_data.c, &channel_data.c,
channel_data.warnings, channel_data.warnings,
); );
@ -278,77 +274,59 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
} }
} }
impl MapResponse<ChannelInfo> for response::ChannelAbout { impl MapResponse<Channel<ChannelInfo>> for response::Channel {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<ChannelInfo>, ExtractionError> { fn map_response(
// Channel info is always fetched in English. There is no localized data self,
// and it allows parsing the country name. id: &str,
let lang = Language::En; lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Channel<ChannelInfo>>, ExtractionError> {
let content = map_channel_content(id, self.contents, self.alerts)?;
let channel_data = map_channel(
MapChannelData {
header: self.header,
metadata: self.metadata,
microformat: self.microformat,
visitor_data: self.response_context.visitor_data,
has_shorts: content.has_shorts,
has_live: content.has_live,
},
id,
lang,
)?;
let ep = match self { let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
response::ChannelAbout::ReceivedEndpoints { mapper.map_response(content.content);
on_response_received_endpoints, let mut warnings = mapper.warnings;
} => on_response_received_endpoints
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData("no received endpoint".into()))?,
response::ChannelAbout::Content { contents } => {
// Handle errors (e.g. age restriction) when regular channel content was returned
map_channel_content(ctx.id, contents, None)?;
return Err(ExtractionError::InvalidData(
"could not extract aboutData".into(),
));
}
};
let continuations = ep.append_continuation_items_action.continuation_items;
let about = continuations
.c
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData("no aboutChannel data".into()))?
.about_channel_renderer
.metadata
.about_channel_view_model;
let mut warnings = continuations.warnings;
let links = about let cinfo = mapper.channel_info.unwrap_or_else(|| {
.links warnings.push("no aboutFullMetadata".to_owned());
.into_iter() ChannelInfo {
.filter_map(|l| { create_date: None,
let lv = l.channel_external_link_view_model; view_count: None,
if let TextComponent::Web { url, .. } = lv.link { links: Vec::new(),
Some((String::from(lv.title), util::sanitize_yt_url(&url)))
} else {
None
} }
}) });
.collect::<Vec<_>>();
Ok(MapResult { Ok(MapResult {
c: ChannelInfo { c: combine_channel_data(channel_data.c, cinfo),
id: about.channel_id,
url: about.canonical_channel_url,
description: about.description,
subscriber_count: about
.subscriber_count_text
.and_then(|txt| util::parse_large_numstr_or_warn(&txt, lang, &mut warnings)),
video_count: about
.video_count_text
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
create_date: about.joined_date_text.and_then(|txt| {
timeago::parse_textual_date_or_warn(lang, ctx.utc_offset, &txt, &mut warnings)
.map(OffsetDateTime::date)
}),
view_count: about
.view_count_text
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
country: about.country.and_then(|c| util::country_from_name(&c)),
links,
},
warnings, warnings,
}) })
} }
} }
fn map_vanity_url(url: &str, id: &str) -> Option<String> {
if url.contains(id) {
return None;
}
Url::parse(url).ok().map(|mut parsed_url| {
// The vanity URL from YouTube is http for some reason
_ = parsed_url.set_scheme("https");
parsed_url.to_string()
})
}
struct MapChannelData { struct MapChannelData {
header: Option<response::channel::Header>, header: Option<response::channel::Header>,
metadata: Option<response::channel::Metadata>, metadata: Option<response::channel::Metadata>,
@ -360,41 +338,36 @@ struct MapChannelData {
fn map_channel( fn map_channel(
d: MapChannelData, d: MapChannelData,
ctx: &MapRespCtx<'_>, id: &str,
lang: Language,
) -> Result<MapResult<Channel<()>>, ExtractionError> { ) -> Result<MapResult<Channel<()>>, ExtractionError> {
let header = d.header.ok_or_else(|| ExtractionError::NotFound { let header = d.header.ok_or_else(|| ExtractionError::NotFound {
id: ctx.id.to_owned(), id: id.to_owned(),
msg: "no header".into(), msg: "no header".into(),
})?; })?;
let metadata = d let metadata = d
.metadata .metadata
.ok_or_else(|| ExtractionError::NotFound { .ok_or_else(|| ExtractionError::NotFound {
id: ctx.id.to_owned(), id: id.to_owned(),
msg: "no metadata".into(), msg: "no metadata".into(),
})? })?
.channel_metadata_renderer; .channel_metadata_renderer;
let microformat = d.microformat.ok_or_else(|| ExtractionError::NotFound { let microformat = d.microformat.ok_or_else(|| ExtractionError::NotFound {
id: ctx.id.to_owned(), id: id.to_owned(),
msg: "no microformat".into(), msg: "no microformat".into(),
})?; })?;
if metadata.external_id != ctx.id { if metadata.external_id != id {
return Err(ExtractionError::WrongResult(format!( return Err(ExtractionError::WrongResult(format!(
"got wrong channel id {}, expected {}", "got wrong channel id {}, expected {}",
metadata.external_id, ctx.id metadata.external_id, id
))); )));
} }
let handle = metadata let vanity_url = metadata
.vanity_channel_url .vanity_channel_url
.as_ref() .as_ref()
.and_then(|url| Url::parse(url).ok()) .and_then(|url| map_vanity_url(url, id));
.and_then(|url| {
url.path()
.strip_prefix('/')
.filter(|handle| util::CHANNEL_HANDLE_REGEX.is_match(handle))
.map(str::to_owned)
});
let mut warnings = Vec::new(); let mut warnings = Vec::new();
Ok(MapResult { Ok(MapResult {
@ -402,16 +375,17 @@ fn map_channel(
response::channel::Header::C4TabbedHeaderRenderer(header) => Channel { response::channel::Header::C4TabbedHeaderRenderer(header) => Channel {
id: metadata.external_id, id: metadata.external_id,
name: metadata.title, name: metadata.title,
handle, subscriber_count: header
subscriber_count: header.subscriber_count_text.and_then(|txt| { .subscriber_count_text
util::parse_large_numstr_or_warn(&txt, ctx.lang, &mut warnings) .and_then(|txt| util::parse_large_numstr_or_warn(&txt, lang, &mut warnings)),
}),
video_count: None,
avatar: header.avatar.into(), avatar: header.avatar.into(),
verification: header.badges.into(), verification: header.badges.into(),
description: metadata.description, description: metadata.description,
tags: microformat.microformat_data_renderer.tags, tags: microformat.microformat_data_renderer.tags,
vanity_url,
banner: header.banner.into(), banner: header.banner.into(),
mobile_banner: header.mobile_banner.into(),
tv_banner: header.tv_banner.into(),
has_shorts: d.has_shorts, has_shorts: d.has_shorts,
has_live: d.has_live, has_live: d.has_live,
visitor_data: d.visitor_data, visitor_data: d.visitor_data,
@ -432,68 +406,19 @@ fn map_channel(
Channel { Channel {
id: metadata.external_id, id: metadata.external_id,
name: metadata.title, name: metadata.title,
handle,
subscriber_count: hdata.as_ref().and_then(|hdata| { subscriber_count: hdata.as_ref().and_then(|hdata| {
hdata.0.as_ref().and_then(|txt| { hdata.0.as_ref().and_then(|txt| {
util::parse_large_numstr_or_warn(txt, ctx.lang, &mut warnings) util::parse_large_numstr_or_warn(txt, lang, &mut warnings)
}) })
}), }),
video_count: None,
avatar: hdata.map(|hdata| hdata.1.into()).unwrap_or_default(), avatar: hdata.map(|hdata| hdata.1.into()).unwrap_or_default(),
// Since the carousel header is only used for YT-internal channels or special events
// (World Cup, Coachella, etc.) we can assume the channel to be verified
verification: crate::model::Verification::Verified, verification: crate::model::Verification::Verified,
description: metadata.description, description: metadata.description,
tags: microformat.microformat_data_renderer.tags, tags: microformat.microformat_data_renderer.tags,
vanity_url,
banner: Vec::new(), banner: Vec::new(),
has_shorts: d.has_shorts, mobile_banner: Vec::new(),
has_live: d.has_live, tv_banner: Vec::new(),
visitor_data: d.visitor_data,
content: (),
}
}
response::channel::Header::PageHeaderRenderer(header) => {
let hdata = header.content.page_header_view_model;
// channel handle - subscriber count - video count
let md_rows = hdata.metadata.content_metadata_view_model.metadata_rows;
let (sub_part, vc_part) = if md_rows.len() > 1 {
let mp = &md_rows[1].metadata_parts;
(mp.first(), mp.get(1))
} else {
(
md_rows.first().and_then(|md| md.metadata_parts.get(1)),
None,
)
};
let subscriber_count = sub_part.and_then(|t| {
util::parse_large_numstr_or_warn::<u64>(t.as_str(), ctx.lang, &mut warnings)
});
let video_count =
vc_part.and_then(|t| util::parse_numeric_or_warn(t.as_str(), &mut warnings));
Channel {
id: metadata.external_id,
name: metadata.title,
handle: handle.or_else(|| {
md_rows
.first()
.and_then(|md| md.metadata_parts.get(1))
.map(|txt| txt.as_str().to_owned())
.filter(|txt| util::CHANNEL_HANDLE_REGEX.is_match(txt))
}),
subscriber_count,
video_count,
avatar: hdata
.image
.decorated_avatar_view_model
.avatar
.avatar_view_model
.image
.into(),
verification: hdata.title.map(Verification::from).unwrap_or_default(),
description: metadata.description,
tags: microformat.microformat_data_renderer.tags,
banner: hdata.banner.image_banner_view_model.image.into(),
has_shorts: d.has_shorts, has_shorts: d.has_shorts,
has_live: d.has_live, has_live: d.has_live,
visitor_data: d.visitor_data, visitor_data: d.visitor_data,
@ -519,6 +444,13 @@ fn map_channel_content(
match contents { match contents {
Some(contents) => { Some(contents) => {
let tabs = contents.two_column_browse_results_renderer.contents; let tabs = contents.two_column_browse_results_renderer.contents;
if tabs.is_empty() {
return Err(ExtractionError::NotFound {
id: id.to_owned(),
msg: "no tabs".into(),
});
}
let cmp_url_suffix = |endpoint: &response::channel::ChannelTabEndpoint, let cmp_url_suffix = |endpoint: &response::channel::ChannelTabEndpoint,
expect: &str| { expect: &str| {
endpoint endpoint
@ -533,41 +465,19 @@ fn map_channel_content(
let mut featured_tab = false; let mut featured_tab = false;
for tab in &tabs { for tab in &tabs {
if let Some(endpoint) = &tab.tab_renderer.endpoint { if cmp_url_suffix(&tab.tab_renderer.endpoint, "/featured")
if cmp_url_suffix(endpoint, "/featured")
&& (tab.tab_renderer.content.section_list_renderer.is_some() && (tab.tab_renderer.content.section_list_renderer.is_some()
|| tab.tab_renderer.content.rich_grid_renderer.is_some()) || tab.tab_renderer.content.rich_grid_renderer.is_some())
{ {
featured_tab = true; featured_tab = true;
} else if cmp_url_suffix(endpoint, "/shorts") { } else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/shorts") {
has_shorts = true; has_shorts = true;
} else if cmp_url_suffix(endpoint, "/streams") { } else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/streams") {
has_live = true; has_live = true;
} }
} else {
// Check for age gate
if let Some(YouTubeListItem::ChannelAgeGateRenderer {
channel_title,
main_text,
}) = &tab
.tab_renderer
.content
.section_list_renderer
.as_ref()
.and_then(|c| c.contents.c.first())
{
return Err(ExtractionError::Unavailable {
reason: crate::error::UnavailabilityReason::AgeRestricted,
msg: format!("{channel_title}: {main_text}"),
});
}
}
} }
let channel_content = tabs let channel_content = tabs.into_iter().find_map(|tab| {
.into_iter()
.filter(|t| t.tab_renderer.endpoint.is_some())
.find_map(|tab| {
tab.tab_renderer tab.tab_renderer
.content .content
.rich_grid_renderer .rich_grid_renderer
@ -581,10 +491,9 @@ fn map_channel_content(
match channel_content { match channel_content {
Some(list) => list.contents, Some(list) => list.contents,
None => { None => {
return Err(ExtractionError::NotFound { return Err(ExtractionError::InvalidData(
id: id.to_owned(), "could not extract content".into(),
msg: "no tabs".into(), ))
});
} }
} }
}; };
@ -603,14 +512,15 @@ fn combine_channel_data<T>(channel_data: Channel<()>, content: T) -> Channel<T>
Channel { Channel {
id: channel_data.id, id: channel_data.id,
name: channel_data.name, name: channel_data.name,
handle: channel_data.handle,
subscriber_count: channel_data.subscriber_count, subscriber_count: channel_data.subscriber_count,
video_count: channel_data.video_count,
avatar: channel_data.avatar, avatar: channel_data.avatar,
verification: channel_data.verification, verification: channel_data.verification,
description: channel_data.description, description: channel_data.description,
tags: channel_data.tags, tags: channel_data.tags,
vanity_url: channel_data.vanity_url,
banner: channel_data.banner, banner: channel_data.banner,
mobile_banner: channel_data.mobile_banner,
tv_banner: channel_data.tv_banner,
has_shorts: channel_data.has_shorts, has_shorts: channel_data.has_shorts,
has_live: channel_data.has_live, has_live: channel_data.has_live,
visitor_data: channel_data.visitor_data, visitor_data: channel_data.visitor_data,
@ -619,7 +529,18 @@ fn combine_channel_data<T>(channel_data: Channel<()>, content: T) -> Channel<T>
} }
/// Get the continuation token to fetch channel videos in the given order /// Get the continuation token to fetch channel videos in the given order
fn order_ctoken( fn order_ctoken(channel_id: &str, tab: ChannelVideoTab, order: ChannelOrder) -> String {
_order_ctoken(
channel_id,
tab,
order,
&format!("\n${}", util::random_uuid()),
)
}
/// Get the continuation token to fetch channel videos in the given order
/// (fixed targetId for testing)
fn _order_ctoken(
channel_id: &str, channel_id: &str,
tab: ChannelVideoTab, tab: ChannelVideoTab,
order: ChannelOrder, order: ChannelOrder,
@ -627,33 +548,7 @@ fn order_ctoken(
) -> String { ) -> String {
let mut pb_tab = ProtoBuilder::new(); let mut pb_tab = ProtoBuilder::new();
pb_tab.string(2, target_id); pb_tab.string(2, target_id);
pb_tab.varint(3, order as u64);
match tab {
ChannelVideoTab::Videos => match order {
ChannelOrder::Latest => {
pb_tab.varint(3, 1);
pb_tab.varint(4, 4);
}
ChannelOrder::Popular => {
pb_tab.varint(3, 2);
pb_tab.varint(4, 2);
}
ChannelOrder::Oldest => {
pb_tab.varint(3, 4);
pb_tab.varint(4, 5);
}
},
ChannelVideoTab::Shorts => match order {
ChannelOrder::Latest => pb_tab.varint(4, 4),
ChannelOrder::Popular => pb_tab.varint(4, 2),
ChannelOrder::Oldest => pb_tab.varint(4, 5),
},
ChannelVideoTab::Live => match order {
ChannelOrder::Latest => pb_tab.varint(5, 12),
ChannelOrder::Popular => pb_tab.varint(5, 14),
ChannelOrder::Oldest => pb_tab.varint(5, 13),
},
}
let mut pb_3 = ProtoBuilder::new(); let mut pb_3 = ProtoBuilder::new();
pb_3.embedded(tab.order_ctoken_id(), pb_tab); pb_3.embedded(tab.order_ctoken_id(), pb_tab);
@ -674,32 +569,6 @@ fn order_ctoken(
pb.to_base64() pb.to_base64()
} }
/// Get the continuation token to fetch channel
fn channel_info_ctoken(channel_id: &str, target_id: &str) -> String {
let mut pb_3 = ProtoBuilder::new();
pb_3.string(19, target_id);
let mut pb_110 = ProtoBuilder::new();
pb_110.embedded(3, pb_3);
let mut pbi = ProtoBuilder::new();
pbi.embedded(110, pb_110);
let mut pb_80226972 = ProtoBuilder::new();
pb_80226972.string(2, channel_id);
pb_80226972.string(3, &pbi.to_base64());
let mut pb = ProtoBuilder::new();
pb.embedded(80_226_972, pb_80226972);
pb.to_base64()
}
/// Create a random UUId to build continuation tokens
fn random_target() -> String {
format!("\n${}", util::random_uuid())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{fs::File, io::BufReader}; use std::{fs::File, io::BufReader};
@ -708,15 +577,14 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use crate::{ use crate::{
client::{response, MapRespCtx, MapResponse}, client::{response, MapResponse},
error::{ExtractionError, UnavailabilityReason},
model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem}, model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem},
param::{ChannelOrder, ChannelVideoTab}, param::{ChannelOrder, ChannelVideoTab, Language},
serializer::MapResult, serializer::MapResult,
util::tests::TESTFILES, util::tests::TESTFILES,
}; };
use super::{channel_info_ctoken, order_ctoken}; use super::_order_ctoken;
#[rstest] #[rstest]
#[case::base("videos_base", "UC2DjFE7Xf11URZqWBigcVOQ")] #[case::base("videos_base", "UC2DjFE7Xf11URZqWBigcVOQ")]
@ -727,12 +595,9 @@ mod tests {
#[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")] #[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")]
#[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")] #[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")] #[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")]
#[case::coachella("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")] #[case::richgrid2("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
#[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")] #[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")] #[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")]
#[case::pageheader("shorts_20240129_pageheader", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::pageheader2("videos_20240324_pageheader2", "UC2DjFE7Xf11URZqWBigcVOQ")]
#[case::lockup("shorts_20240910_lockup", "UCh8gHdtzO2tXd593_bjErWg")]
fn map_channel_videos(#[case] name: &str, #[case] id: &str) { fn map_channel_videos(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "channel" / format!("channel_{name}.json")); let json_path = path!(*TESTFILES / "channel" / format!("channel_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
@ -740,7 +605,7 @@ mod tests {
let channel: response::Channel = let channel: response::Channel =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Channel<Paginator<VideoItem>>> = let map_res: MapResult<Channel<Paginator<VideoItem>>> =
channel.map_response(&MapRespCtx::test(id)).unwrap(); channel.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -759,34 +624,15 @@ mod tests {
} }
} }
#[test]
fn channel_agegate() {
let json_path = path!(*TESTFILES / "channel" / format!("channel_agegate.json"));
let json_file = File::open(json_path).unwrap();
let channel: response::Channel =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let res: Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> =
channel.map_response(&MapRespCtx::test("UCbfnHqxXs_K3kvaH-WlNlig"));
if let Err(ExtractionError::Unavailable { reason, msg }) = res {
assert_eq!(reason, UnavailabilityReason::AgeRestricted);
assert!(msg.starts_with("Laphroaig Whisky: "));
} else {
panic!("invalid res: {res:?}")
}
}
#[rstest] #[rstest]
#[case::base("base")] fn map_channel_playlists() {
#[case::lockup("20241109_lockup")] let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json");
fn map_channel_playlists(#[case] name: &str) {
let json_path = path!(*TESTFILES / "channel" / format!("channel_playlists_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let channel: response::Channel = let channel: response::Channel =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Channel<Paginator<PlaylistItem>>> = channel let map_res: MapResult<Channel<Paginator<PlaylistItem>>> = channel
.map_response(&MapRespCtx::test("UC2DjFE7Xf11URZqWBigcVOQ")) .map_response("UC2DjFE7Xf11URZqWBigcVOQ", Language::En, None)
.unwrap(); .unwrap();
assert!( assert!(
@ -794,7 +640,7 @@ mod tests {
"deserialization/mapping warnings: {:?}", "deserialization/mapping warnings: {:?}",
map_res.warnings map_res.warnings
); );
insta::assert_ron_snapshot!(format!("map_channel_playlists_{name}"), map_res.c); insta::assert_ron_snapshot!("map_channel_playlists", map_res.c);
} }
#[rstest] #[rstest]
@ -802,10 +648,10 @@ mod tests {
let json_path = path!(*TESTFILES / "channel" / "channel_info.json"); let json_path = path!(*TESTFILES / "channel" / "channel_info.json");
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let channel: response::ChannelAbout = let channel: response::Channel =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<ChannelInfo> = channel let map_res: MapResult<Channel<ChannelInfo>> = channel
.map_response(&MapRespCtx::test("UC2DjFE7Xf11U-RZqWBigcVOQ")) .map_response("UC2DjFE7Xf11URZqWBigcVOQ", Language::En, None)
.unwrap(); .unwrap();
assert!( assert!(
@ -817,39 +663,31 @@ mod tests {
} }
#[test] #[test]
fn t_order_ctoken() { fn order_ctoken() {
let channel_id = "UCXuqSBlHAE6Xw-yeJA0Tunw"; let channel_id = "UCXuqSBlHAE6Xw-yeJA0Tunw";
let videos_popular_token = order_ctoken( let videos_popular_token = _order_ctoken(
channel_id, channel_id,
ChannelVideoTab::Videos, ChannelVideoTab::Videos,
ChannelOrder::Popular, ChannelOrder::Popular,
"\n$6461d7c8-0000-2040-87aa-089e0827e420", "\n$6461d7c8-0000-2040-87aa-089e0827e420",
); );
assert_eq!(videos_popular_token, "4qmFsgJgEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaRDhnWXdHaTU2TEJJbUNpUTJORFl4WkRkak9DMHdNREF3TFRJd05EQXRPRGRoWVMwd09EbGxNRGd5TjJVME1qQVlBaUFD"); assert_eq!(videos_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXg2S2hJbUNpUTJORFl4WkRkak9DMHdNREF3TFRJd05EQXRPRGRoWVMwd09EbGxNRGd5TjJVME1qQVlBZyUzRCUzRA%3D%3D");
let shorts_popular_token = order_ctoken( let shorts_popular_token = _order_ctoken(
channel_id, channel_id,
ChannelVideoTab::Shorts, ChannelVideoTab::Shorts,
ChannelOrder::Popular, ChannelOrder::Popular,
"\n$64679ffb-0000-26b3-a1bd-582429d2c794", "\n$64679ffb-0000-26b3-a1bd-582429d2c794",
); );
assert_eq!(shorts_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXhTS2hJbUNpUTJORFkzT1dabVlpMHdNREF3TFRJMllqTXRZVEZpWkMwMU9ESTBNamxrTW1NM09UUWdBZyUzRCUzRA%3D%3D"); assert_eq!(shorts_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXhTS2hJbUNpUTJORFkzT1dabVlpMHdNREF3TFRJMllqTXRZVEZpWkMwMU9ESTBNamxrTW1NM09UUVlBZyUzRCUzRA%3D%3D");
let live_popular_token = order_ctoken( let live_popular_token = _order_ctoken(
channel_id, channel_id,
ChannelVideoTab::Live, ChannelVideoTab::Live,
ChannelOrder::Popular, ChannelOrder::Popular,
"\n$64693069-0000-2a1e-8c7d-582429bd5ba8", "\n$64693069-0000-2a1e-8c7d-582429bd5ba8",
); );
assert_eq!(live_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXh5S2hJbUNpUTJORFk1TXpBMk9TMHdNREF3TFRKaE1XVXRPR00zWkMwMU9ESTBNamxpWkRWaVlUZ29EZyUzRCUzRA%3D%3D"); assert_eq!(live_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXh5S2hJbUNpUTJORFk1TXpBMk9TMHdNREF3TFRKaE1XVXRPR00zWkMwMU9ESTBNamxpWkRWaVlUZ1lBZyUzRCUzRA%3D%3D");
}
#[test]
fn t_channel_info_ctoken() {
let channel_id = "UCh8gHdtzO2tXd593_bjErWg";
let token = channel_info_ctoken(channel_id, "\n$655b339a-0000-20b9-92dc-582429d254b4");
assert_eq!(token, "4qmFsgJgEhhVQ2g4Z0hkdHpPMnRYZDU5M19iakVyV2caRDhnWXJHaW1hQVNZS0pEWTFOV0l6TXpsaExUQXdNREF0TWpCaU9TMDVNbVJqTFRVNE1qUXlPV1F5TlRSaU5BJTNEJTNE");
} }
} }

View file

@ -1,10 +1,9 @@
use std::fmt::Debug; use std::collections::BTreeMap;
use crate::{ use crate::{
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::ChannelRss, model::ChannelRss,
report::Report, report::{Report, RustyPipeInfo},
util,
}; };
use super::{response, RustyPipeQuery}; use super::{response, RustyPipeQuery};
@ -18,11 +17,7 @@ impl RustyPipeQuery {
/// for checking a lot of channels or implementing a subscription feed. /// for checking a lot of channels or implementing a subscription feed.
/// ///
/// The downside of using the RSS feed is that it does not provide video durations. /// The downside of using the RSS feed is that it does not provide video durations.
#[tracing::instrument(skip(self), level = "error")] pub async fn channel_rss<S: AsRef<str>>(&self, channel_id: S) -> Result<ChannelRss, Error> {
pub async fn channel_rss<S: AsRef<str> + Debug>(
&self,
channel_id: S,
) -> Result<ChannelRss, Error> {
let channel_id = channel_id.as_ref(); let channel_id = channel_id.as_ref();
let url = format!("https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"); let url = format!("https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}");
let xml = self let xml = self
@ -37,15 +32,12 @@ impl RustyPipeQuery {
_ => e, _ => e,
})?; })?;
match quick_xml::de::from_str::<response::ChannelRss>(&xml) match quick_xml::de::from_str::<response::ChannelRss>(&xml) {
.map_err(|e| ExtractionError::InvalidData(e.to_string().into())) Ok(feed) => Ok(feed.into()),
.and_then(|feed| feed.map_response(channel_id))
{
Ok(res) => Ok(res),
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: self.rp_info(), info: RustyPipeInfo::default(),
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()),
@ -54,78 +46,22 @@ impl RustyPipeQuery {
http_request: crate::report::HTTPRequest { http_request: crate::report::HTTPRequest {
url: &url, url: &url,
method: "GET", method: "GET",
req_header: BTreeMap::new(),
req_body: String::new(),
status: 200, status: 200,
req_header: None,
req_body: None,
resp_body: xml, resp_body: xml,
}, },
}; };
reporter.report(&report); reporter.report(&report);
} }
Err(Error::Extraction(e))
}
}
}
}
impl response::ChannelRss { Err(
fn map_response(self, id: &str) -> Result<ChannelRss, ExtractionError> { ExtractionError::InvalidData(format!("could not deserialize xml: {e}").into())
let channel_id = if self.channel_id.is_empty() { .into(),
self.entry )
.iter()
.find_map(|entry| {
Some(entry.channel_id.as_str())
.filter(|id| id.is_empty())
.map(str::to_owned)
})
.or_else(|| {
self.author
.uri
.strip_prefix("https://www.youtube.com/channel/")
.and_then(|id| {
if util::CHANNEL_ID_REGEX.is_match(id) {
Some(id.to_owned())
} else {
None
} }
})
})
.ok_or(ExtractionError::InvalidData(
"could not get channel id".into(),
))?
} else if self.channel_id.len() == 22 {
// As of November 2023, YouTube seems to output channel IDs without the UC prefix
format!("UC{}", self.channel_id)
} else {
self.channel_id
};
if channel_id != id {
return Err(ExtractionError::WrongResult(format!(
"got wrong channel id {channel_id}, expected {id}",
)));
} }
Ok(ChannelRss {
id: channel_id,
name: self.title,
videos: self
.entry
.into_iter()
.map(|item| crate::model::ChannelRssVideo {
id: item.video_id,
name: item.title,
description: item.media_group.description,
thumbnail: item.media_group.thumbnail.into(),
publish_date: item.published,
update_date: item.updated,
view_count: item.media_group.community.statistics.views,
like_count: item.media_group.community.rating.count,
})
.collect(),
create_date: self.create_date,
})
} }
} }
@ -133,24 +69,24 @@ impl response::ChannelRss {
mod tests { mod tests {
use std::{fs::File, io::BufReader}; use std::{fs::File, io::BufReader};
use crate::{client::response, util::tests::TESTFILES}; use crate::{client::response, model::ChannelRss, util::tests::TESTFILES};
use path_macro::path; use path_macro::path;
use rstest::rstest; use rstest::rstest;
#[rstest] #[rstest]
#[case::base("base", "UCHnyfMqiRRG1u-2MsSQLbXA")] #[case::base("base")]
#[case::no_likes("no_likes", "UCdfxp4cUWsWryZOy-o427dw")] #[case::no_likes("no_likes")]
#[case::no_channel_id("no_channel_id", "UCHnyfMqiRRG1u-2MsSQLbXA")] #[case::no_channel_id("no_channel_id")]
#[case::trimmed_channel_id("trimmed_channel_id", "UCHnyfMqiRRG1u-2MsSQLbXA")] fn map_channel_rss(#[case] name: &str) {
fn map_channel_rss(#[case] name: &str, #[case] id: &str) {
let xml_path = path!(*TESTFILES / "channel_rss" / format!("{}.xml", name)); let xml_path = path!(*TESTFILES / "channel_rss" / format!("{}.xml", name));
let xml_file = File::open(xml_path).unwrap(); let xml_file = File::open(xml_path).unwrap();
let feed: response::ChannelRss = let feed: response::ChannelRss =
quick_xml::de::from_reader(BufReader::new(xml_file)).unwrap(); quick_xml::de::from_reader(BufReader::new(xml_file)).unwrap();
let map_res = feed.map_response(id).unwrap(); let map_res: ChannelRss = feed.into();
insta::assert_ron_snapshot!(format!("map_channel_rss_{}", name), map_res); insta::assert_ron_snapshot!(format!("map_channel_rss_{}", name), map_res);
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -2,26 +2,17 @@ use std::borrow::Cow;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use tracing::debug;
use crate::{ use crate::{
client::{
response::{music_item::map_album_type, url_endpoint::NavigationEndpoint},
MapRespOptions, QContinuation,
},
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{AlbumItem, ArtistId, MusicArtist},
paginator::Paginator, traits::FromYtItem, AlbumItem, AlbumType, ArtistId, MusicArtist,
MusicItem,
},
param::{AlbumFilter, AlbumOrder},
serializer::MapResult, serializer::MapResult,
util::{self, ProtoBuilder}, util,
}; };
use super::{ use super::{
response::{self, music_item::MusicListMapper, url_endpoint::PageType}, response::{self, music_item::MusicListMapper, url_endpoint::PageType},
ClientType, MapRespCtx, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery, ClientType, MapResponse, QBrowse, RustyPipeQuery,
}; };
impl RustyPipeQuery { impl RustyPipeQuery {
@ -37,7 +28,7 @@ impl RustyPipeQuery {
let res = self._music_artist(artist_id, all_albums).await; let res = self._music_artist(artist_id, all_albums).await;
if let Err(Error::Extraction(ExtractionError::Redirect(id))) = res { if let Err(Error::Extraction(ExtractionError::Redirect(id))) = res {
debug!("music artist {} redirects to {}", artist_id, &id); log::debug!("music artist {} redirects to {}", artist_id, &id);
self._music_artist(&id, all_albums).await self._music_artist(&id, all_albums).await
} else { } else {
res res
@ -45,7 +36,9 @@ impl RustyPipeQuery {
} }
async fn _music_artist(&self, artist_id: &str, all_albums: bool) -> Result<MusicArtist, Error> { async fn _music_artist(&self, artist_id: &str, all_albums: bool) -> Result<MusicArtist, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: artist_id, browse_id: artist_id,
}; };
@ -61,9 +54,7 @@ impl RustyPipeQuery {
.await?; .await?;
if can_fetch_more { if can_fetch_more {
artist.albums = self artist.albums = self.music_artist_albums(artist_id).await?;
.music_artist_albums(artist_id, None, Some(AlbumOrder::Recency))
.await?;
} }
Ok(artist) Ok(artist)
@ -80,59 +71,32 @@ impl RustyPipeQuery {
} }
/// Get a list of all albums of a YouTube Music artist /// Get a list of all albums of a YouTube Music artist
pub async fn music_artist_albums( pub async fn music_artist_albums(&self, artist_id: &str) -> Result<Vec<AlbumItem>, Error> {
&self, let context = self.get_context(ClientType::DesktopMusic, true, None).await;
artist_id: &str, let request_body = QBrowse {
filter: Option<AlbumFilter>, context,
order: Option<AlbumOrder>,
) -> Result<Vec<AlbumItem>, Error> {
let request_body = QBrowseParams {
browse_id: &format!("{}{}", util::ARTIST_DISCOGRAPHY_PREFIX, artist_id), browse_id: &format!("{}{}", util::ARTIST_DISCOGRAPHY_PREFIX, artist_id),
params: &albums_param(filter, order),
}; };
let first_page = self self.execute_request::<response::MusicArtistAlbums, _, _>(
.execute_request::<response::MusicArtistAlbums, _, _>(
ClientType::DesktopMusic, ClientType::DesktopMusic,
"music_artist_albums", "music_artist_albums",
artist_id, artist_id,
"browse", "browse",
&request_body, &request_body,
) )
.await?; .await
let mut albums = first_page.albums;
let mut ctoken = first_page.ctoken;
while let Some(tkn) = &ctoken {
let request_body = QContinuation { continuation: tkn };
let resp: Paginator<MusicItem> = self
.execute_request_ctx::<response::MusicContinuation, Paginator<MusicItem>, _>(
ClientType::DesktopMusic,
"music_artist_albums_cont",
artist_id,
"browse",
&request_body,
MapRespOptions {
artist: Some(first_page.artist.clone()),
visitor_data: first_page.visitor_data.as_deref(),
..Default::default()
},
)
.await?;
if resp.items.is_empty() {
tracing::warn!("artist albums [{artist_id}] empty continuation");
}
ctoken = resp.ctoken;
albums.extend(resp.items.into_iter().filter_map(AlbumItem::from_ytm_item));
}
Ok(albums)
} }
} }
impl MapResponse<MusicArtist> for response::MusicArtist { impl MapResponse<MusicArtist> for response::MusicArtist {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicArtist>, ExtractionError> { fn map_response(
let mapped = map_artist_page(self, ctx, false)?; self,
id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicArtist>, ExtractionError> {
let mapped = map_artist_page(self, id, lang, false)?;
Ok(MapResult { Ok(MapResult {
c: mapped.c.0, c: mapped.c.0,
warnings: mapped.warnings, warnings: mapped.warnings,
@ -143,15 +107,18 @@ impl MapResponse<MusicArtist> for response::MusicArtist {
impl MapResponse<(MusicArtist, bool)> for response::MusicArtist { impl MapResponse<(MusicArtist, bool)> for response::MusicArtist {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> { ) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> {
map_artist_page(self, ctx, true) map_artist_page(self, id, lang, true)
} }
} }
fn map_artist_page( fn map_artist_page(
res: response::MusicArtist, res: response::MusicArtist,
ctx: &MapRespCtx<'_>, id: &str,
lang: crate::param::Language,
skip_extendables: bool, skip_extendables: bool,
) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> { ) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> {
// dbg!(&res); // dbg!(&res);
@ -167,7 +134,7 @@ fn map_artist_page(
.and_then(|pb| util::string_from_pb(pb, 3)); .and_then(|pb| util::string_from_pb(pb, 3));
if let Some(share_channel_id) = share_channel_id { if let Some(share_channel_id) = share_channel_id {
if share_channel_id != ctx.id { if share_channel_id != id {
return Err(ExtractionError::Redirect(share_channel_id)); return Err(ExtractionError::Redirect(share_channel_id));
} }
} }
@ -179,13 +146,14 @@ fn map_artist_page(
.contents .contents
.into_iter() .into_iter()
.next() .next()
.map(|c| c.tab_renderer.content.section_list_renderer.contents) .and_then(|tab| tab.tab_renderer.content)
.map(|c| c.section_list_renderer.contents)
.unwrap_or_default(); .unwrap_or_default();
let mut mapper = MusicListMapper::with_artist( let mut mapper = MusicListMapper::with_artist(
ctx.lang, lang,
ArtistId { ArtistId {
id: Some(ctx.id.to_owned()), id: Some(id.to_owned()),
name: header.title.clone(), name: header.title.clone(),
}, },
); );
@ -210,56 +178,50 @@ fn map_artist_page(
} }
} }
} }
mapper.album_type = AlbumType::Single;
mapper.map_response(shelf.contents); mapper.map_response(shelf.contents);
} }
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
let mut extendable_albums = false; let mut extendable_albums = false;
mapper.album_type = AlbumType::Single;
if let Some(h) = shelf.header { if let Some(h) = shelf.header {
if let Some(button) = h if let Some(button) = h
.music_carousel_shelf_basic_header_renderer .music_carousel_shelf_basic_header_renderer
.more_content_button .more_content_button
{ {
if let NavigationEndpoint::Browse { if let Some(bep) =
browse_endpoint, .. button.button_renderer.navigation_endpoint.browse_endpoint
} = button.button_renderer.navigation_endpoint
{ {
if let Some(cfg) = bep.browse_endpoint_context_supported_configs {
match cfg.browse_endpoint_context_music_config.page_type {
// Music videos // Music videos
if browse_endpoint PageType::Playlist => {
.browse_endpoint_context_supported_configs
.map(|cfg| {
cfg.browse_endpoint_context_music_config.page_type
== PageType::Playlist
})
.unwrap_or_default()
{
if videos_playlist_id.is_none() { if videos_playlist_id.is_none() {
videos_playlist_id = Some(browse_endpoint.browse_id); videos_playlist_id = Some(bep.browse_id);
} }
} else if browse_endpoint }
.browse_id // Albums
.starts_with(util::ARTIST_DISCOGRAPHY_PREFIX) PageType::ArtistDiscography => {
{
can_fetch_more = true; can_fetch_more = true;
extendable_albums = true; extendable_albums = true;
} else { }
// Albums or playlists
PageType::Artist => {
// Peek at the first item to determine type // Peek at the first item to determine type
if let Some(response::music_item::MusicResponseItem::MusicTwoRowItemRenderer(item)) = shelf.contents.c.first() { if let Some(response::music_item::MusicResponseItem::MusicTwoRowItemRenderer(item)) = shelf.contents.c.first() {
if let Some(PageType::Album) = item.navigation_endpoint.page_type() { if let Some(PageType::Album) = item.navigation_endpoint.browse_endpoint.as_ref().and_then(|be| {
be.browse_endpoint_context_supported_configs.as_ref().map(|config| {
config.browse_endpoint_context_music_config.page_type
})}) {
can_fetch_more = true; can_fetch_more = true;
extendable_albums = true; extendable_albums = true;
} }
} }
} }
_ => {}
}
}
} }
} }
mapper.album_type = map_album_type(
h.music_carousel_shelf_basic_header_renderer
.title
.first_str(),
ctx.lang,
);
} }
if !skip_extendables || !extendable_albums { if !skip_extendables || !extendable_albums {
@ -270,6 +232,7 @@ fn map_artist_page(
} }
} }
mapper.check_unknown()?;
let mut mapped = mapper.group_items(); let mut mapped = mapper.group_items();
static WIKIPEDIA_REGEX: Lazy<Regex> = static WIKIPEDIA_REGEX: Lazy<Regex> =
@ -288,18 +251,16 @@ fn map_artist_page(
}); });
let radio_id = header.start_radio_button.and_then(|b| { let radio_id = header.start_radio_button.and_then(|b| {
if let NavigationEndpoint::Watch { watch_endpoint } = b.button_renderer.navigation_endpoint b.button_renderer
{ .navigation_endpoint
watch_endpoint.playlist_id .watch_endpoint
} else { .and_then(|w| w.playlist_id)
None
}
}); });
Ok(MapResult { Ok(MapResult {
c: ( c: (
MusicArtist { MusicArtist {
id: ctx.id.to_owned(), id: id.to_owned(),
name: header.title, name: header.title,
header_image: header.thumbnail.into(), header_image: header.thumbnail.into(),
description: header.description, description: header.description,
@ -307,7 +268,7 @@ fn map_artist_page(
subscriber_count: header.subscription_button.and_then(|btn| { subscriber_count: header.subscription_button.and_then(|btn| {
util::parse_large_numstr_or_warn( util::parse_large_numstr_or_warn(
&btn.subscribe_button_renderer.subscriber_count_text, &btn.subscribe_button_renderer.subscriber_count_text,
ctx.lang, lang,
&mut mapped.warnings, &mut mapped.warnings,
) )
}), }),
@ -325,26 +286,17 @@ fn map_artist_page(
}) })
} }
#[derive(Debug)] impl MapResponse<Vec<AlbumItem>> for response::MusicArtistAlbums {
struct FirstAlbumPage {
albums: Vec<AlbumItem>,
ctoken: Option<String>,
artist: ArtistId,
visitor_data: Option<String>,
}
impl MapResponse<FirstAlbumPage> for response::MusicArtistAlbums {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, id: &str,
) -> Result<MapResult<FirstAlbumPage>, ExtractionError> { lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Vec<AlbumItem>>, ExtractionError> {
// dbg!(&self); // dbg!(&self);
let Some(header) = self.header else { let Some(header) = self.header else {
return Err(ExtractionError::NotFound { return Err(ExtractionError::NotFound { id: id.into(), msg: "no header".into() });
id: ctx.id.into(),
msg: "no header".into(),
});
}; };
let grids = self let grids = self
@ -359,56 +311,28 @@ impl MapResponse<FirstAlbumPage> for response::MusicArtistAlbums {
.section_list_renderer .section_list_renderer
.contents; .contents;
let artist_id = ArtistId { let mut mapper = MusicListMapper::with_artist(
id: Some(ctx.id.to_owned()), lang,
ArtistId {
id: Some(id.to_owned()),
name: header.music_header_renderer.title, name: header.music_header_renderer.title,
}; },
let mut mapper = MusicListMapper::with_artist(ctx.lang, artist_id.clone()); );
let mut ctoken = None;
for grid in grids { for grid in grids {
mapper.map_response(grid.grid_renderer.items); mapper.map_response(grid.grid_renderer.items);
if ctoken.is_none() {
ctoken = grid
.grid_renderer
.continuations
.into_iter()
.next()
.map(|g| g.next_continuation_data.continuation);
}
} }
mapper.check_unknown()?;
let mapped = mapper.group_items(); let mapped = mapper.group_items();
Ok(MapResult { Ok(MapResult {
c: FirstAlbumPage { c: mapped.c.albums,
albums: mapped.c.albums,
ctoken,
artist: artist_id,
visitor_data: ctx.visitor_data.map(str::to_owned),
},
warnings: mapped.warnings, warnings: mapped.warnings,
}) })
} }
} }
fn albums_param(filter: Option<AlbumFilter>, order: Option<AlbumOrder>) -> String {
let mut pb_filter = ProtoBuilder::new();
if let Some(filter) = filter {
pb_filter.varint(1, filter as u64);
}
if let Some(order) = order {
pb_filter.varint(2, order as u64);
}
pb_filter.bytes(3, &[1, 2]);
let mut pb_48 = ProtoBuilder::new();
pb_48.embedded(15, pb_filter);
let mut pb_3 = ProtoBuilder::new();
pb_3.embedded(48, pb_48);
pb_3.to_base64()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{fs::File, io::BufReader}; use std::{fs::File, io::BufReader};
@ -416,7 +340,7 @@ mod tests {
use path_macro::path; use path_macro::path;
use rstest::rstest; use rstest::rstest;
use crate::util::tests::TESTFILES; use crate::{param::Language, util::tests::TESTFILES};
use super::*; use super::*;
@ -425,7 +349,6 @@ mod tests {
#[case::only_singles("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw")] #[case::only_singles("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw")]
#[case::no_artist("no_artist", "UCh8gHdtzO2tXd593_bjErWg")] #[case::no_artist("no_artist", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::only_more_singles("only_more_singles", "UC0aXrjVxG5pZr99v77wZdPQ")] #[case::only_more_singles("only_more_singles", "UC0aXrjVxG5pZr99v77wZdPQ")]
#[case::grouped_albums("20250113_grouped_albums", "UCOR4_bSVIXPsGa4BbCSt60Q")]
fn map_music_artist(#[case] name: &str, #[case] id: &str) { fn map_music_artist(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "music_artist" / format!("artist_{name}.json")); let json_path = path!(*TESTFILES / "music_artist" / format!("artist_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
@ -439,7 +362,7 @@ mod tests {
let resp: response::MusicArtist = let resp: response::MusicArtist =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<(MusicArtist, bool)> = let map_res: MapResult<(MusicArtist, bool)> =
resp.map_response(&MapRespCtx::test(id)).unwrap(); resp.map_response(id, Language::En, None).unwrap();
let (mut artist, can_fetch_more) = map_res.c; let (mut artist, can_fetch_more) = map_res.c;
assert!( assert!(
@ -449,42 +372,19 @@ mod tests {
); );
assert_eq!(can_fetch_more, album_page_path.is_some()); assert_eq!(can_fetch_more, album_page_path.is_some());
// Album overview
if let Some(album_page_path) = album_page_path { if let Some(album_page_path) = album_page_path {
let json_file = File::open(album_page_path).unwrap(); let json_file = File::open(album_page_path).unwrap();
let resp: response::MusicArtistAlbums = let resp: response::MusicArtistAlbums =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<FirstAlbumPage> = let mut map_res: MapResult<Vec<AlbumItem>> =
resp.map_response(&MapRespCtx::test(id)).unwrap(); resp.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}", "deserialization/mapping warnings: {:?}",
map_res.warnings map_res.warnings
); );
artist.albums = map_res.c.albums; artist.albums.append(&mut map_res.c);
// Album overview continuation
for i in 2..10 {
let cont_path =
path!(*TESTFILES / "music_artist" / format!("artist_{name}_{i}.json"));
if !cont_path.is_file() {
break;
}
let json_file = File::open(cont_path).unwrap();
let resp: response::MusicContinuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<MusicItem>> =
resp.map_response(&MapRespCtx::test(id)).unwrap();
assert!(!map_res.c.items.is_empty());
artist.albums.extend(
map_res
.c
.items
.into_iter()
.filter_map(AlbumItem::from_ytm_item),
);
}
} }
insta::assert_ron_snapshot!(format!("map_music_artist_{name}"), artist); insta::assert_ron_snapshot!(format!("map_music_artist_{name}"), artist);
@ -498,7 +398,7 @@ mod tests {
let artist: response::MusicArtist = let artist: response::MusicArtist =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicArtist> = artist let map_res: MapResult<MusicArtist> = artist
.map_response(&MapRespCtx::test("UClmXPfaYhXOYsNn_QUyheWQ")) .map_response("UClmXPfaYhXOYsNn_QUyheWQ", Language::En, None)
.unwrap(); .unwrap();
assert!( assert!(
@ -517,12 +417,12 @@ mod tests {
let artist: response::MusicArtist = let artist: response::MusicArtist =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let res: Result<MapResult<MusicArtist>, ExtractionError> = let res: Result<MapResult<MusicArtist>, ExtractionError> =
artist.map_response(&MapRespCtx::test("UCLkAepWjdylmXSltofFvsYQ")); artist.map_response("UCLkAepWjdylmXSltofFvsYQ", Language::En, None);
let e = res.unwrap_err(); let e = res.unwrap_err();
match e { match e {
ExtractionError::Redirect(id) => { ExtractionError::Redirect(id) => {
assert_eq!(id, "UCOR4_bSVIXPsGa4BbCSt60Q"); assert_eq!(id, "UCOR4_bSVIXPsGa4BbCSt60Q")
} }
_ => panic!("error: {e}"), _ => panic!("error: {e}"),
} }

View file

@ -11,12 +11,13 @@ use crate::{
use super::{ use super::{
response::{self, music_item::MusicListMapper, url_endpoint::MusicPageType}, response::{self, music_item::MusicListMapper, url_endpoint::MusicPageType},
ClientType, MapRespCtx, MapResponse, RustyPipeQuery, ClientType, MapResponse, RustyPipeQuery, YTContext,
}; };
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QCharts<'a> { struct QCharts<'a> {
context: YTContext<'a>,
browse_id: &'a str, browse_id: &'a str,
params: &'a str, params: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -31,9 +32,10 @@ struct FormData {
impl RustyPipeQuery { impl RustyPipeQuery {
/// Get the YouTube Music charts for a given country /// Get the YouTube Music charts for a given country
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_charts(&self, country: Option<Country>) -> Result<MusicCharts, Error> { pub async fn music_charts(&self, country: Option<Country>) -> Result<MusicCharts, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QCharts { let request_body = QCharts {
context,
browse_id: "FEmusic_charts", browse_id: "FEmusic_charts",
params: "sgYPRkVtdXNpY19leHBsb3Jl", params: "sgYPRkVtdXNpY19leHBsb3Jl",
form_data: country.map(|c| FormData { form_data: country.map(|c| FormData {
@ -53,7 +55,12 @@ impl RustyPipeQuery {
} }
impl MapResponse<MusicCharts> for response::MusicCharts { impl MapResponse<MusicCharts> for response::MusicCharts {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicCharts>, ExtractionError> { fn map_response(
self,
_id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<crate::serializer::MapResult<MusicCharts>, crate::error::ExtractionError> {
let countries = self let countries = self
.framework_updates .framework_updates
.map(|fwu| { .map(|fwu| {
@ -68,9 +75,9 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
let mut top_playlist_id = None; let mut top_playlist_id = None;
let mut trending_playlist_id = None; let mut trending_playlist_id = None;
let mut mapper_top = MusicListMapper::new(ctx.lang); let mut mapper_top = MusicListMapper::new(lang);
let mut mapper_trending = MusicListMapper::new(ctx.lang); let mut mapper_trending = MusicListMapper::new(lang);
let mut mapper_other = MusicListMapper::new(ctx.lang); let mut mapper_other = MusicListMapper::new(lang);
self.contents self.contents
.single_column_browse_results_renderer .single_column_browse_results_renderer
@ -89,9 +96,8 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
h.music_carousel_shelf_basic_header_renderer h.music_carousel_shelf_basic_header_renderer
.more_content_button .more_content_button
.and_then(|btn| btn.button_renderer.navigation_endpoint.music_page()) .and_then(|btn| btn.button_renderer.navigation_endpoint.music_page())
.map(|mp| (mp.typ, mp.id))
}) { }) {
Some((MusicPageType::Playlist { .. }, id)) => { Some((MusicPageType::Playlist, id)) => {
// Top music videos (first shelf with associated playlist) // Top music videos (first shelf with associated playlist)
if top_playlist_id.is_none() { if top_playlist_id.is_none() {
mapper_top.map_response(shelf.contents); mapper_top.map_response(shelf.contents);
@ -112,13 +118,17 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
response::music_charts::ItemSection::None => {} response::music_charts::ItemSection::None => {}
}); });
mapper_top.check_unknown()?;
mapper_trending.check_unknown()?;
mapper_other.check_unknown()?;
let mapped_top = mapper_top.conv_items::<TrackItem>(); let mapped_top = mapper_top.conv_items::<TrackItem>();
let mapped_trending = mapper_trending.conv_items::<TrackItem>(); let mut mapped_trending = mapper_trending.conv_items::<TrackItem>();
let mapped_other = mapper_other.group_items(); let mut mapped_other = mapper_other.group_items();
let mut warnings = mapped_top.warnings; let mut warnings = mapped_top.warnings;
warnings.extend(mapped_trending.warnings); warnings.append(&mut mapped_trending.warnings);
warnings.extend(mapped_other.warnings); warnings.append(&mut mapped_other.warnings);
Ok(MapResult { Ok(MapResult {
c: MusicCharts { c: MusicCharts {
@ -142,6 +152,7 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use super::*; use super::*;
use crate::param::Language;
#[rstest] #[rstest]
#[case::default("global")] #[case::default("global")]
@ -153,7 +164,7 @@ mod tests {
let charts: response::MusicCharts = let charts: response::MusicCharts =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicCharts> = charts.map_response(&MapRespCtx::test("")).unwrap(); let map_res: MapResult<MusicCharts> = charts.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -1,13 +1,11 @@
use std::{borrow::Cow, fmt::Debug}; use std::borrow::Cow;
use serde::Serialize; use serde::Serialize;
use crate::{ use crate::{
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{paginator::Paginator, ArtistId, Lyrics, MusicRelated, TrackDetails, TrackItem},
paginator::{ContinuationEndpoint, Paginator}, param::Language,
ArtistId, Lyrics, MusicRelated, TrackDetails, TrackItem,
},
serializer::MapResult, serializer::MapResult,
}; };
@ -16,11 +14,12 @@ use super::{
self, self,
music_item::{map_queue_item, MusicListMapper}, music_item::{map_queue_item, MusicListMapper},
}, },
ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery, ClientType, MapResponse, QBrowse, RustyPipeQuery, YTContext,
}; };
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct QMusicDetails<'a> { struct QMusicDetails<'a> {
context: YTContext<'a>,
video_id: &'a str, video_id: &'a str,
enable_persistent_playlist_panel: bool, enable_persistent_playlist_panel: bool,
is_audio_only: bool, is_audio_only: bool,
@ -29,6 +28,7 @@ struct QMusicDetails<'a> {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct QRadio<'a> { struct QRadio<'a> {
context: YTContext<'a>,
playlist_id: &'a str, playlist_id: &'a str,
params: &'a str, params: &'a str,
enable_persistent_playlist_panel: bool, enable_persistent_playlist_panel: bool,
@ -37,14 +37,12 @@ struct QRadio<'a> {
} }
impl RustyPipeQuery { impl RustyPipeQuery {
/// Get the metadata of a YouTube Music track /// Get the metadata of a YouTube music track
#[tracing::instrument(skip(self), level = "error")] pub async fn music_details<S: AsRef<str>>(&self, video_id: S) -> Result<TrackDetails, Error> {
pub async fn music_details<S: AsRef<str> + Debug>(
&self,
video_id: S,
) -> Result<TrackDetails, Error> {
let video_id = video_id.as_ref(); let video_id = video_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QMusicDetails { let request_body = QMusicDetails {
context,
video_id, video_id,
enable_persistent_playlist_panel: true, enable_persistent_playlist_panel: true,
is_audio_only: true, is_audio_only: true,
@ -61,13 +59,14 @@ impl RustyPipeQuery {
.await .await
} }
/// Get the lyrics of a YouTube Music track /// Get the lyrics of a YouTube music track
/// ///
/// The `lyrics_id` has to be obtained using [`RustyPipeQuery::music_details`]. /// The `lyrics_id` has to be obtained using [`RustyPipeQuery::music_details`].
#[tracing::instrument(skip(self), level = "error")] pub async fn music_lyrics<S: AsRef<str>>(&self, lyrics_id: S) -> Result<Lyrics, Error> {
pub async fn music_lyrics<S: AsRef<str> + Debug>(&self, lyrics_id: S) -> Result<Lyrics, Error> {
let lyrics_id = lyrics_id.as_ref(); let lyrics_id = lyrics_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: lyrics_id, browse_id: lyrics_id,
}; };
@ -84,13 +83,11 @@ impl RustyPipeQuery {
/// Get related items (tracks, playlists, artists) to a YouTube Music track /// Get related items (tracks, playlists, artists) to a YouTube Music track
/// ///
/// The `related_id` has to be obtained using [`RustyPipeQuery::music_details`]. /// The `related_id` has to be obtained using [`RustyPipeQuery::music_details`].
#[tracing::instrument(skip(self), level = "error")] pub async fn music_related<S: AsRef<str>>(&self, related_id: S) -> Result<MusicRelated, Error> {
pub async fn music_related<S: AsRef<str> + Debug>(
&self,
related_id: S,
) -> Result<MusicRelated, Error> {
let related_id = related_id.as_ref(); let related_id = related_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: related_id, browse_id: related_id,
}; };
@ -107,13 +104,17 @@ impl RustyPipeQuery {
/// Get a YouTube Music radio (a dynamically generated playlist) /// Get a YouTube Music radio (a dynamically generated playlist)
/// ///
/// The `radio_id` can be obtained using [`RustyPipeQuery::music_artist`] to get an artist's radio. /// The `radio_id` can be obtained using [`RustyPipeQuery::music_artist`] to get an artist's radio.
#[tracing::instrument(skip(self), level = "error")] pub async fn music_radio<S: AsRef<str>>(
pub async fn music_radio<S: AsRef<str> + Debug>(
&self, &self,
radio_id: S, radio_id: S,
) -> Result<Paginator<TrackItem>, Error> { ) -> Result<Paginator<TrackItem>, Error> {
let radio_id = radio_id.as_ref(); let radio_id = radio_id.as_ref();
let visitor_data = self.get_visitor_data().await?;
let context = self
.get_context(ClientType::DesktopMusic, true, Some(&visitor_data))
.await;
let request_body = QRadio { let request_body = QRadio {
context,
playlist_id: radio_id, playlist_id: radio_id,
params: "wAEB8gECeAE%3D", params: "wAEB8gECeAE%3D",
enable_persistent_playlist_panel: true, enable_persistent_playlist_panel: true,
@ -132,8 +133,7 @@ impl RustyPipeQuery {
} }
/// Get a YouTube Music radio (a dynamically generated playlist) for a track /// Get a YouTube Music radio (a dynamically generated playlist) for a track
#[tracing::instrument(skip(self), level = "error")] pub async fn music_radio_track<S: AsRef<str>>(
pub async fn music_radio_track<S: AsRef<str> + Debug>(
&self, &self,
video_id: S, video_id: S,
) -> Result<Paginator<TrackItem>, Error> { ) -> Result<Paginator<TrackItem>, Error> {
@ -142,8 +142,7 @@ impl RustyPipeQuery {
} }
/// Get a YouTube Music radio (a dynamically generated playlist) for a playlist /// Get a YouTube Music radio (a dynamically generated playlist) for a playlist
#[tracing::instrument(skip(self), level = "error")] pub async fn music_radio_playlist<S: AsRef<str>>(
pub async fn music_radio_playlist<S: AsRef<str> + Debug>(
&self, &self,
playlist_id: S, playlist_id: S,
) -> Result<Paginator<TrackItem>, Error> { ) -> Result<Paginator<TrackItem>, Error> {
@ -155,7 +154,9 @@ impl RustyPipeQuery {
impl MapResponse<TrackDetails> for response::MusicDetails { impl MapResponse<TrackDetails> for response::MusicDetails {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<TrackDetails>, ExtractionError> { ) -> Result<MapResult<TrackDetails>, ExtractionError> {
let tabs = self let tabs = self
.contents .contents
@ -193,7 +194,7 @@ impl MapResponse<TrackDetails> for response::MusicDetails {
} }
let content = content.ok_or_else(|| ExtractionError::NotFound { let content = content.ok_or_else(|| ExtractionError::NotFound {
id: ctx.id.to_owned(), id: id.to_owned(),
msg: "no content".into(), msg: "no content".into(),
})?; })?;
let track_item = content let track_item = content
@ -207,7 +208,14 @@ impl MapResponse<TrackDetails> for response::MusicDetails {
response::music_item::PlaylistPanelVideo::None => None, response::music_item::PlaylistPanelVideo::None => None,
}) })
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no video item")))?; .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no video item")))?;
let mut track = map_queue_item(track_item, ctx.lang); let mut track = map_queue_item(track_item, lang);
if track.c.id != id {
return Err(ExtractionError::WrongResult(format!(
"got wrong video id {}, expected {}",
track.c.id, id
)));
}
let mut warnings = content.contents.warnings; let mut warnings = content.contents.warnings;
warnings.append(&mut track.warnings); warnings.append(&mut track.warnings);
@ -226,7 +234,9 @@ impl MapResponse<TrackDetails> for response::MusicDetails {
impl MapResponse<Paginator<TrackItem>> for response::MusicDetails { impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> { ) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
let tabs = self let tabs = self
.contents .contents
@ -239,7 +249,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
.into_iter() .into_iter()
.find_map(|t| t.tab_renderer.content) .find_map(|t| t.tab_renderer.content)
.ok_or_else(|| ExtractionError::NotFound { .ok_or_else(|| ExtractionError::NotFound {
id: ctx.id.to_owned(), id: id.to_owned(),
msg: "no content".into(), msg: "no content".into(),
})? })?
.music_queue_renderer .music_queue_renderer
@ -254,7 +264,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
.into_iter() .into_iter()
.filter_map(|item| match item { .filter_map(|item| match item {
response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) => { response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) => {
let mut track = map_queue_item(item, ctx.lang); let mut track = map_queue_item(item, lang);
warnings.append(&mut track.warnings); warnings.append(&mut track.warnings);
Some(track.c) Some(track.c)
} }
@ -274,8 +284,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
tracks, tracks,
ctoken, ctoken,
None, None,
ContinuationEndpoint::MusicNext, crate::model::paginator::ContinuationEndpoint::MusicNext,
false,
), ),
warnings, warnings,
}) })
@ -283,17 +292,27 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
} }
impl MapResponse<Lyrics> for response::MusicLyrics { impl MapResponse<Lyrics> for response::MusicLyrics {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<Lyrics>, ExtractionError> { fn map_response(
self,
id: &str,
_lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Lyrics>, ExtractionError> {
let lyrics = self let lyrics = self
.contents .contents
.into_res() .section_list_renderer
.map_err(|msg| ExtractionError::NotFound { .and_then(|sl| {
id: ctx.id.to_owned(), sl.contents
msg: msg.into(),
})?
.into_iter() .into_iter()
.find_map(|item| item.music_description_shelf_renderer) .find_map(|item| item.music_description_shelf_renderer)
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?; })
.ok_or(match self.contents.message_renderer {
Some(msg) => ExtractionError::NotFound {
id: id.to_owned(),
msg: msg.text.into(),
},
None => ExtractionError::InvalidData(Cow::Borrowed("no content")),
})?;
Ok(MapResult { Ok(MapResult {
c: Lyrics { c: Lyrics {
@ -308,18 +327,17 @@ impl MapResponse<Lyrics> for response::MusicLyrics {
impl MapResponse<MusicRelated> for response::MusicRelated { impl MapResponse<MusicRelated> for response::MusicRelated {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, _id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicRelated>, ExtractionError> { ) -> Result<MapResult<MusicRelated>, ExtractionError> {
let contents = self
.contents
.into_res()
.map_err(|msg| ExtractionError::NotFound {
id: ctx.id.to_owned(),
msg: msg.into(),
})?;
// Find artist // Find artist
let artist_id = contents.iter().find_map(|section| match section { let artist_id = self
.contents
.section_list_renderer
.contents
.iter()
.find_map(|section| match section {
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
shelf.header.as_ref().and_then(|h| { shelf.header.as_ref().and_then(|h| {
h.music_carousel_shelf_basic_header_renderer h.music_carousel_shelf_basic_header_renderer
@ -339,13 +357,13 @@ impl MapResponse<MusicRelated> for response::MusicRelated {
_ => None, _ => None,
}); });
let mut mapper_tracks = MusicListMapper::new(ctx.lang); let mut mapper_tracks = MusicListMapper::new(lang);
let mut mapper = match artist_id { let mut mapper = match artist_id {
Some(artist_id) => MusicListMapper::with_artist(ctx.lang, artist_id), Some(artist_id) => MusicListMapper::with_artist(lang, artist_id),
None => MusicListMapper::new(ctx.lang), None => MusicListMapper::new(lang),
}; };
let mut sections = contents.into_iter(); let mut sections = self.contents.section_list_renderer.contents.into_iter();
if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf)) = if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf)) =
sections.next() sections.next()
{ {
@ -362,6 +380,9 @@ impl MapResponse<MusicRelated> for response::MusicRelated {
_ => {} _ => {}
}); });
mapper.check_unknown()?;
mapper_tracks.check_unknown()?;
let mapped_tracks = mapper_tracks.conv_items(); let mapped_tracks = mapper_tracks.conv_items();
let mut mapped = mapper.group_items(); let mut mapped = mapper.group_items();
@ -389,7 +410,7 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use super::*; use super::*;
use crate::{model, util::tests::TESTFILES}; use crate::{model, param::Language, util::tests::TESTFILES};
#[rstest] #[rstest]
#[case::mv("mv", "ZeerrnuLi5E")] #[case::mv("mv", "ZeerrnuLi5E")]
@ -401,7 +422,7 @@ mod tests {
let details: response::MusicDetails = let details: response::MusicDetails =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<model::TrackDetails> = let map_res: MapResult<model::TrackDetails> =
details.map_response(&MapRespCtx::test(id)).unwrap(); details.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -421,7 +442,7 @@ mod tests {
let radio: response::MusicDetails = let radio: response::MusicDetails =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<TrackItem>> = let map_res: MapResult<Paginator<TrackItem>> =
radio.map_response(&MapRespCtx::test(id)).unwrap(); radio.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -438,7 +459,7 @@ mod tests {
let lyrics: response::MusicLyrics = let lyrics: response::MusicLyrics =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Lyrics> = lyrics.map_response(&MapRespCtx::test("")).unwrap(); let map_res: MapResult<Lyrics> = lyrics.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -455,7 +476,7 @@ mod tests {
let lyrics: response::MusicRelated = let lyrics: response::MusicRelated =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicRelated> = lyrics.map_response(&MapRespCtx::test("")).unwrap(); let map_res: MapResult<MusicRelated> = lyrics.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -1,4 +1,4 @@
use std::{borrow::Cow, fmt::Debug}; use std::borrow::Cow;
use crate::{ use crate::{
error::{Error, ExtractionError}, error::{Error, ExtractionError},
@ -7,15 +7,16 @@ use crate::{
}; };
use super::{ use super::{
response::{self, music_item::MusicListMapper, url_endpoint::NavigationEndpoint}, response::{self, music_item::MusicListMapper},
ClientType, MapRespCtx, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery, ClientType, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery,
}; };
impl RustyPipeQuery { impl RustyPipeQuery {
/// Get a list of moods and genres from YouTube Music /// Get a list of moods and genres from YouTube Music
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_genres(&self) -> Result<Vec<MusicGenreItem>, Error> { pub async fn music_genres(&self) -> Result<Vec<MusicGenreItem>, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: "FEmusic_moods_and_genres", browse_id: "FEmusic_moods_and_genres",
}; };
@ -30,13 +31,11 @@ impl RustyPipeQuery {
} }
/// Get the playlists from a YouTube Music genre /// Get the playlists from a YouTube Music genre
#[tracing::instrument(skip(self), level = "error")] pub async fn music_genre<S: AsRef<str>>(&self, genre_id: S) -> Result<MusicGenre, Error> {
pub async fn music_genre<S: AsRef<str> + Debug>(
&self,
genre_id: S,
) -> Result<MusicGenre, Error> {
let genre_id = genre_id.as_ref(); let genre_id = genre_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowseParams { let request_body = QBrowseParams {
context,
browse_id: "FEmusic_moods_and_genres_category", browse_id: "FEmusic_moods_and_genres_category",
params: genre_id, params: genre_id,
}; };
@ -55,8 +54,10 @@ impl RustyPipeQuery {
impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres { impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres {
fn map_response( fn map_response(
self, self,
_ctx: &MapRespCtx<'_>, _id: &str,
) -> Result<MapResult<Vec<MusicGenreItem>>, ExtractionError> { _lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<crate::serializer::MapResult<Vec<MusicGenreItem>>, ExtractionError> {
let content = self let content = self
.contents .contents
.single_column_browse_results_renderer .single_column_browse_results_renderer
@ -104,7 +105,12 @@ impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres {
} }
impl MapResponse<MusicGenre> for response::MusicGenre { impl MapResponse<MusicGenre> for response::MusicGenre {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicGenre>, ExtractionError> { fn map_response(
self,
id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<crate::serializer::MapResult<MusicGenre>, ExtractionError> {
// dbg!(&self); // dbg!(&self);
let content = self let content = self
@ -138,21 +144,19 @@ impl MapResponse<MusicGenre> for response::MusicGenre {
h.music_carousel_shelf_basic_header_renderer h.music_carousel_shelf_basic_header_renderer
.more_content_button .more_content_button
.and_then(|btn| { .and_then(|btn| {
if let NavigationEndpoint::Browse { btn.button_renderer
browse_endpoint, .. .navigation_endpoint
} = btn.button_renderer.navigation_endpoint .browse_endpoint
{ .and_then(|browse| {
if browse_endpoint.browse_id if browse.browse_id
== "FEmusic_moods_and_genres_category" == "FEmusic_moods_and_genres_category"
{ {
Some(browse_endpoint.params) Some(browse.params)
} else {
None
}
} else { } else {
None None
} }
}) })
})
}), }),
shelf.contents, shelf.contents,
), ),
@ -166,7 +170,7 @@ impl MapResponse<MusicGenre> for response::MusicGenre {
_ => return None, _ => return None,
}; };
let mut mapper = MusicListMapper::new(ctx.lang); let mut mapper = MusicListMapper::new(lang);
mapper.map_response(items); mapper.map_response(items);
let mut mapped = mapper.conv_items(); let mut mapped = mapper.conv_items();
warnings.append(&mut mapped.warnings); warnings.append(&mut mapped.warnings);
@ -181,7 +185,7 @@ impl MapResponse<MusicGenre> for response::MusicGenre {
Ok(MapResult { Ok(MapResult {
c: MusicGenre { c: MusicGenre {
id: ctx.id.to_owned(), id: id.to_owned(),
name: self.header.music_header_renderer.title, name: self.header.music_header_renderer.title,
sections, sections,
}, },
@ -198,7 +202,7 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use super::*; use super::*;
use crate::{model, util::tests::TESTFILES}; use crate::{model, param::Language, util::tests::TESTFILES};
#[test] #[test]
fn map_music_genres() { fn map_music_genres() {
@ -208,7 +212,7 @@ mod tests {
let playlist: response::MusicGenres = let playlist: response::MusicGenres =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Vec<model::MusicGenreItem>> = let map_res: MapResult<Vec<model::MusicGenreItem>> =
playlist.map_response(&MapRespCtx::test("")).unwrap(); playlist.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -228,7 +232,7 @@ mod tests {
let playlist: response::MusicGenre = let playlist: response::MusicGenre =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<model::MusicGenre> = let map_res: MapResult<model::MusicGenre> =
playlist.map_response(&MapRespCtx::test(id)).unwrap(); playlist.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -4,16 +4,16 @@ use crate::{
client::response::music_item::MusicListMapper, client::response::music_item::MusicListMapper,
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{traits::FromYtItem, AlbumItem, TrackItem}, model::{traits::FromYtItem, AlbumItem, TrackItem},
serializer::MapResult,
}; };
use super::{response, ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery}; use super::{response, ClientType, MapResponse, QBrowse, RustyPipeQuery};
impl RustyPipeQuery { impl RustyPipeQuery {
/// Get the new albums that were released on YouTube Music /// Get the new albums that were released on YouTube Music
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_new_albums(&self) -> Result<Vec<AlbumItem>, Error> { pub async fn music_new_albums(&self) -> Result<Vec<AlbumItem>, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: "FEmusic_new_releases_albums", browse_id: "FEmusic_new_releases_albums",
}; };
@ -28,9 +28,10 @@ impl RustyPipeQuery {
} }
/// Get the new music videos that were released on YouTube Music /// Get the new music videos that were released on YouTube Music
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_new_videos(&self) -> Result<Vec<TrackItem>, Error> { pub async fn music_new_videos(&self) -> Result<Vec<TrackItem>, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: "FEmusic_new_releases_videos", browse_id: "FEmusic_new_releases_videos",
}; };
@ -46,7 +47,12 @@ impl RustyPipeQuery {
} }
impl<T: FromYtItem> MapResponse<Vec<T>> for response::MusicNew { impl<T: FromYtItem> MapResponse<Vec<T>> for response::MusicNew {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<Vec<T>>, ExtractionError> { fn map_response(
self,
_id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<crate::serializer::MapResult<Vec<T>>, ExtractionError> {
let items = self let items = self
.contents .contents
.single_column_browse_results_renderer .single_column_browse_results_renderer
@ -64,8 +70,9 @@ impl<T: FromYtItem> MapResponse<Vec<T>> for response::MusicNew {
.grid_renderer .grid_renderer
.items; .items;
let mut mapper = MusicListMapper::new(ctx.lang); let mut mapper = MusicListMapper::new(lang);
mapper.map_response(items); mapper.map_response(items);
mapper.check_unknown()?;
Ok(mapper.conv_items()) Ok(mapper.conv_items())
} }
@ -79,7 +86,7 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use super::*; use super::*;
use crate::{serializer::MapResult, util::tests::TESTFILES}; use crate::{param::Language, serializer::MapResult, util::tests::TESTFILES};
#[rstest] #[rstest]
#[case::default("default")] #[case::default("default")]
@ -90,7 +97,7 @@ mod tests {
let new_albums: response::MusicNew = let new_albums: response::MusicNew =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Vec<AlbumItem>> = let map_res: MapResult<Vec<AlbumItem>> =
new_albums.map_response(&MapRespCtx::test("")).unwrap(); new_albums.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -102,15 +109,14 @@ mod tests {
#[rstest] #[rstest]
#[case::default("default")] #[case::default("default")]
#[case::default("w_podcasts")]
fn map_music_new_videos(#[case] name: &str) { fn map_music_new_videos(#[case] name: &str) {
let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json")); let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let new_videos: response::MusicNew = let new_albums: response::MusicNew =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Vec<TrackItem>> = let map_res: MapResult<Vec<TrackItem>> =
new_videos.map_response(&MapRespCtx::test("")).unwrap(); new_albums.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -1,36 +1,30 @@
use std::{borrow::Cow, fmt::Debug}; use std::borrow::Cow;
use crate::{ use crate::{
client::response::url_endpoint::NavigationEndpoint,
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{paginator::Paginator, AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem},
paginator::{ContinuationEndpoint, Paginator}, serializer::MapResult,
richtext::RichText,
AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem, TrackType,
},
serializer::{text::TextComponents, MapResult},
util::{self, TryRemove, DOT_SEPARATOR}, util::{self, TryRemove, DOT_SEPARATOR},
}; };
use self::response::url_endpoint::MusicPageType;
use super::{ use super::{
response::{ response::{
self, self,
music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper}, music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper},
}, },
ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery, ClientType, MapResponse, QBrowse, RustyPipeQuery,
}; };
impl RustyPipeQuery { impl RustyPipeQuery {
/// Get a playlist from YouTube Music /// Get a playlist from YouTube Music
#[tracing::instrument(skip(self), level = "error")] pub async fn music_playlist<S: AsRef<str>>(
pub async fn music_playlist<S: AsRef<str> + Debug>(
&self, &self,
playlist_id: S, playlist_id: S,
) -> Result<MusicPlaylist, Error> { ) -> Result<MusicPlaylist, Error> {
let playlist_id = playlist_id.as_ref(); let playlist_id = playlist_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: &format!("VL{playlist_id}"), browse_id: &format!("VL{playlist_id}"),
}; };
@ -45,13 +39,11 @@ impl RustyPipeQuery {
} }
/// Get an album from YouTube Music /// Get an album from YouTube Music
#[tracing::instrument(skip(self), level = "error")] pub async fn music_album<S: AsRef<str>>(&self, album_id: S) -> Result<MusicAlbum, Error> {
pub async fn music_album<S: AsRef<str> + Debug>(
&self,
album_id: S,
) -> Result<MusicAlbum, Error> {
let album_id = album_id.as_ref(); let album_id = album_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: album_id, browse_id: album_id,
}; };
@ -87,7 +79,7 @@ impl RustyPipeQuery {
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(i, track)| { .filter_map(|(i, track)| {
if track.track_type.is_video() { if track.is_video {
Some((i, track.name.clone())) Some((i, track.name.clone()))
} else { } else {
None None
@ -104,7 +96,7 @@ impl RustyPipeQuery {
for (i, title) in to_replace { for (i, title) in to_replace {
let found_track = playlist.tracks.items.iter().find_map(|track| { let found_track = playlist.tracks.items.iter().find_map(|track| {
if track.name == title && track.track_type.is_track() { if track.name == title && !track.is_video {
Some((track.id.clone(), track.duration)) Some((track.id.clone(), track.duration))
} else { } else {
None None
@ -115,7 +107,7 @@ impl RustyPipeQuery {
if let Some(duration) = duration { if let Some(duration) = duration {
album.tracks[i].duration = Some(duration); album.tracks[i].duration = Some(duration);
} }
album.tracks[i].track_type = TrackType::Track; album.tracks[i].is_video = false;
} }
} }
} }
@ -127,39 +119,22 @@ impl RustyPipeQuery {
impl MapResponse<MusicPlaylist> for response::MusicPlaylist { impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicPlaylist>, ExtractionError> { ) -> Result<MapResult<MusicPlaylist>, ExtractionError> {
// dbg!(&self); // dbg!(&self);
let (header, music_contents) = match self.contents { let music_contents = self
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => ( .contents
self.header, .single_column_browse_results_renderer
c.contents .contents
.into_iter() .into_iter()
.next() .next()
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
.tab_renderer .tab_renderer
.content .content
.section_list_renderer, .section_list_renderer;
),
response::music_playlist::Contents::TwoColumnBrowseResultsRenderer {
secondary_contents,
tabs,
} => (
tabs.into_iter()
.next()
.and_then(|t| {
t.tab_renderer
.content
.section_list_renderer
.contents
.into_iter()
.next()
})
.or(self.header),
secondary_contents.section_list_renderer,
),
};
let shelf = music_contents let shelf = music_contents
.contents .contents
.into_iter() .into_iter()
@ -172,28 +147,26 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
)))?; )))?;
if let Some(playlist_id) = shelf.playlist_id { if let Some(playlist_id) = shelf.playlist_id {
if playlist_id != ctx.id { if playlist_id != id {
return Err(ExtractionError::WrongResult(format!( return Err(ExtractionError::WrongResult(format!(
"got wrong playlist id {}, expected {}", "got wrong playlist id {playlist_id}, expected {id}"
playlist_id, ctx.id
))); )));
} }
} }
let mut mapper = MusicListMapper::new(ctx.lang); let mut mapper = MusicListMapper::new(lang);
mapper.map_response(shelf.contents); mapper.map_response(shelf.contents);
mapper.check_unknown()?;
let map_res = mapper.conv_items();
let ctoken = mapper.ctoken.clone().or_else(|| { let ctoken = shelf
shelf
.continuations .continuations
.into_iter() .into_iter()
.next() .next()
.map(|cont| cont.next_continuation_data.continuation) .map(|cont| cont.next_continuation_data.continuation);
});
let map_res = mapper.conv_items();
let track_count = if ctoken.is_some() { let track_count = if ctoken.is_some() {
header.as_ref().and_then(|h| { self.header.as_ref().and_then(|h| {
let parts = h let parts = h
.music_detail_header_renderer .music_detail_header_renderer
.second_subtitle .second_subtitle
@ -213,49 +186,23 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
.next() .next()
.map(|c| c.next_continuation_data.continuation); .map(|c| c.next_continuation_data.continuation);
let (from_ytm, channel, name, thumbnail, description) = match header { let (from_ytm, channel, name, thumbnail, description) = match self.header {
Some(header) => { Some(header) => {
let h = header.music_detail_header_renderer; let h = header.music_detail_header_renderer;
let (from_ytm, channel) = match h.facepile { let from_ytm = h.subtitle.0.iter().any(util::is_ytm);
Some(facepile) => { let channel = h
let from_ytm = facepile.avatar_stack_view_model.text.starts_with("YouTube"); .subtitle
let channel = facepile .0
.avatar_stack_view_model .into_iter()
.renderer_context .find_map(|c| ChannelId::try_from(c).ok());
.command_context
.and_then(|c| {
c.on_tap
.innertube_command
.music_page()
.filter(|p| p.typ == MusicPageType::User)
.map(|p| p.id)
})
.map(|id| ChannelId {
id,
name: facepile.avatar_stack_view_model.text,
});
(from_ytm && channel.is_none(), channel)
}
None => {
let st = match h.strapline_text_one {
Some(s) => s,
None => h.subtitle,
};
let from_ytm = st.0.iter().any(util::is_ytm);
let channel = st.0.into_iter().find_map(|c| ChannelId::try_from(c).ok());
(from_ytm, channel)
}
};
( (
from_ytm, from_ytm,
channel, channel,
h.title, h.title,
h.thumbnail.into(), h.thumbnail.into(),
h.description.map(TextComponents::from), h.description,
) )
} }
None => { None => {
@ -287,28 +234,26 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
Ok(MapResult { Ok(MapResult {
c: MusicPlaylist { c: MusicPlaylist {
id: ctx.id.to_owned(), id: id.to_owned(),
name, name,
thumbnail, thumbnail,
channel, channel,
description: description.map(RichText::from), description,
track_count, track_count,
from_ytm, from_ytm,
tracks: Paginator::new_ext( tracks: Paginator::new_ext(
track_count, track_count,
map_res.c, map_res.c,
ctoken, ctoken,
ctx.visitor_data.map(str::to_owned), None,
ContinuationEndpoint::MusicBrowse, crate::model::paginator::ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
), ),
related_playlists: Paginator::new_ext( related_playlists: Paginator::new_ext(
None, None,
Vec::new(), Vec::new(),
related_ctoken, related_ctoken,
ctx.visitor_data.map(str::to_owned), None,
ContinuationEndpoint::MusicBrowse, crate::model::paginator::ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
), ),
}, },
warnings: map_res.warnings, warnings: map_res.warnings,
@ -317,42 +262,30 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
} }
impl MapResponse<MusicAlbum> for response::MusicPlaylist { impl MapResponse<MusicAlbum> for response::MusicPlaylist {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicAlbum>, ExtractionError> { fn map_response(
self,
id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicAlbum>, ExtractionError> {
// dbg!(&self); // dbg!(&self);
let (header, sections) = match self.contents { let header = self
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => ( .header
self.header, .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no header")))?
c.contents .music_detail_header_renderer;
let sections = self
.contents
.single_column_browse_results_renderer
.contents
.into_iter() .into_iter()
.next() .next()
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
.tab_renderer .tab_renderer
.content .content
.section_list_renderer .section_list_renderer
.contents, .contents;
),
response::music_playlist::Contents::TwoColumnBrowseResultsRenderer {
secondary_contents,
tabs,
} => (
tabs.into_iter()
.next()
.and_then(|t| {
t.tab_renderer
.content
.section_list_renderer
.contents
.into_iter()
.next()
})
.or(self.header),
secondary_contents.section_list_renderer.contents,
),
};
let header = header
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no header")))?
.music_detail_header_renderer;
let mut shelf = None; let mut shelf = None;
let mut album_variants = None; let mut album_variants = None;
@ -371,21 +304,12 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR); let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR);
let (year_txt, artists_p) = match header.strapline_text_one { let (year_txt, artists_p) = match subtitle_split.len() {
// New (2column) album layout
Some(sl) => {
let year_txt = subtitle_split
.try_swap_remove(1)
.and_then(|t| t.0.first().map(|c| c.as_str().to_owned()));
(year_txt, Some(sl))
}
// Old album layout
None => match subtitle_split.len() {
3.. => { 3.. => {
let year_txt = subtitle_split let year_txt = subtitle_split
.swap_remove(2) .swap_remove(2)
.0 .0
.first() .get(0)
.map(|c| c.as_str().to_owned()); .map(|c| c.as_str().to_owned());
(year_txt, subtitle_split.try_swap_remove(1)) (year_txt, subtitle_split.try_swap_remove(1))
} }
@ -401,7 +325,6 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
} }
} }
_ => (None, None), _ => (None, None),
},
}; };
let (artists, by_va) = map_artists(artists_p); let (artists, by_va) = map_artists(artists_p);
@ -411,58 +334,35 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
.map(|part| part.to_string()) .map(|part| part.to_string())
.unwrap_or_default(); .unwrap_or_default();
let album_type = map_album_type(album_type_txt.as_str(), ctx.lang); let album_type = map_album_type(album_type_txt.as_str(), lang);
let year = year_txt.and_then(|txt| util::parse_numeric(&txt).ok()); let year = year_txt.and_then(|txt| util::parse_numeric(&txt).ok());
fn map_playlist_id(ep: &NavigationEndpoint) -> Option<String> { let (artist_id, playlist_id) = header
if let NavigationEndpoint::WatchPlaylist {
watch_playlist_endpoint,
} = ep
{
Some(watch_playlist_endpoint.playlist_id.to_owned())
} else {
None
}
}
let playlist_id = self.microformat.and_then(|mf| {
mf.microformat_data_renderer
.url_canonical
.strip_prefix("https://music.youtube.com/playlist?list=")
.map(str::to_owned)
});
let (playlist_id, artist_id) = header
.menu .menu
.or_else(|| header.buttons.into_iter().next())
.map(|menu| { .map(|menu| {
( (
playlist_id.or_else(|| { map_artist_id(menu.menu_renderer.items),
menu.menu_renderer menu.menu_renderer
.top_level_buttons .top_level_buttons
.iter() .into_iter()
.find_map(|btn| { .next()
map_playlist_id(&btn.button_renderer.navigation_endpoint) .map(|btn| {
}) btn.button_renderer
.or_else(|| { .navigation_endpoint
menu.menu_renderer.items.iter().find_map(|itm| { .watch_playlist_endpoint
map_playlist_id( .playlist_id
&itm.menu_navigation_item_renderer.navigation_endpoint,
)
})
})
}), }),
map_artist_id(menu.menu_renderer.items),
) )
}) })
.unwrap_or_default(); .unwrap_or_default();
let artist_id = artist_id.or_else(|| artists.first().and_then(|a| a.id.clone())); let artist_id = artist_id.or_else(|| artists.first().and_then(|a| a.id.clone()));
let mut mapper = MusicListMapper::with_album( let mut mapper = MusicListMapper::with_album(
ctx.lang, lang,
artists.clone(), artists.clone(),
by_va, by_va,
AlbumId { AlbumId {
id: ctx.id.to_owned(), id: id.to_owned(),
name: header.title.clone(), name: header.title.clone(),
}, },
); );
@ -470,7 +370,7 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
let tracks_res = mapper.conv_items(); let tracks_res = mapper.conv_items();
let mut warnings = tracks_res.warnings; let mut warnings = tracks_res.warnings;
let mut variants_mapper = MusicListMapper::new(ctx.lang); let mut variants_mapper = MusicListMapper::new(lang);
if let Some(res) = album_variants { if let Some(res) = album_variants {
variants_mapper.map_response(res); variants_mapper.map_response(res);
} }
@ -479,15 +379,13 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
Ok(MapResult { Ok(MapResult {
c: MusicAlbum { c: MusicAlbum {
id: ctx.id.to_owned(), id: id.to_owned(),
playlist_id, playlist_id,
name: header.title, name: header.title,
cover: header.thumbnail.into(), cover: header.thumbnail.into(),
artists, artists,
artist_id, artist_id,
description: header description: header.description,
.description
.map(|t| RichText::from(TextComponents::from(t))),
album_type, album_type,
year, year,
by_va, by_va,
@ -507,15 +405,12 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use super::*; use super::*;
use crate::{model, util::tests::TESTFILES}; use crate::{model, param::Language, util::tests::TESTFILES};
#[rstest] #[rstest]
#[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")]
#[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")]
#[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
#[case::two_columns("20240228_twoColumns", "RDCLAK5uy_kb7EBi6y3GrtJri4_ZH56Ms786DFEimbM")]
#[case::n_album("20240228_album", "OLAK5uy_kdSWBZ-9AZDkYkuy0QCc3p0KO9DEHVNH0")]
#[case::facepile("20241125_facepile", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
fn map_music_playlist(#[case] name: &str, #[case] id: &str) { fn map_music_playlist(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "music_playlist" / format!("playlist_{name}.json")); let json_path = path!(*TESTFILES / "music_playlist" / format!("playlist_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
@ -523,7 +418,7 @@ mod tests {
let playlist: response::MusicPlaylist = let playlist: response::MusicPlaylist =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<model::MusicPlaylist> = let map_res: MapResult<model::MusicPlaylist> =
playlist.map_response(&MapRespCtx::test(id)).unwrap(); playlist.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -541,8 +436,6 @@ mod tests {
#[case::single("single", "MPREb_bHfHGoy7vuv")] #[case::single("single", "MPREb_bHfHGoy7vuv")]
#[case::description("description", "MPREb_PiyfuVl6aYd")] #[case::description("description", "MPREb_PiyfuVl6aYd")]
#[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")] #[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")]
#[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")]
#[case::two_columns("20240228_twoColumns", "MPREb_bHfHGoy7vuv")]
fn map_music_album(#[case] name: &str, #[case] id: &str) { fn map_music_album(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "music_playlist" / format!("album_{name}.json")); let json_path = path!(*TESTFILES / "music_playlist" / format!("album_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
@ -550,7 +443,7 @@ mod tests {
let playlist: response::MusicPlaylist = let playlist: response::MusicPlaylist =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<model::MusicAlbum> = let map_res: MapResult<model::MusicAlbum> =
playlist.map_response(&MapRespCtx::test(id)).unwrap(); playlist.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -1,4 +1,4 @@
use std::{borrow::Cow, fmt::Debug}; use std::borrow::Cow;
use serde::Serialize; use serde::Serialize;
@ -6,45 +6,96 @@ use crate::{
client::response::music_item::MusicListMapper, client::response::music_item::MusicListMapper,
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{
paginator::{ContinuationEndpoint, Paginator}, paginator::Paginator, traits::FromYtItem, AlbumItem, ArtistItem, MusicPlaylistItem,
traits::FromYtItem, MusicSearchFiltered, MusicSearchResult, MusicSearchSuggestion, TrackItem,
AlbumItem, ArtistItem, MusicItem, MusicPlaylistItem, MusicSearchResult,
MusicSearchSuggestion, TrackItem, UserItem,
}, },
param::search_filter::MusicSearchFilter,
serializer::MapResult, serializer::MapResult,
}; };
use super::{response, ClientType, MapRespCtx, MapResponse, RustyPipeQuery}; use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QSearch<'a> { struct QSearch<'a> {
context: YTContext<'a>,
query: &'a str, query: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
params: Option<&'a str>, params: Option<Params>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QSearchSuggestion<'a> { struct QSearchSuggestion<'a> {
context: YTContext<'a>,
input: &'a str, input: &'a str,
} }
#[derive(Debug, Serialize)]
enum Params {
#[serde(rename = "EgWKAQIIAWoMEAMQBBAJEA4QChAF")]
Tracks,
#[serde(rename = "EgWKAQIQAWoMEAMQBBAJEA4QChAF")]
Videos,
#[serde(rename = "EgWKAQIYAWoMEAMQBBAJEA4QChAF")]
Albums,
#[serde(rename = "EgWKAQIgAWoMEAMQBBAJEA4QChAF")]
Artists,
#[serde(rename = "EgWKAQIoAWoMEAMQBBAJEA4QChAF")]
Playlists,
#[serde(rename = "EgeKAQQoADgBagwQAxAEEAkQDhAKEAU%3D")]
YtmPlaylists,
#[serde(rename = "EgeKAQQoAEABagwQAxAEEAkQDhAKEAU%3D")]
CommunityPlaylists,
}
impl RustyPipeQuery { impl RustyPipeQuery {
/// Search YouTube Music. /// Search YouTube Music. Returns items from any type.
/// pub async fn music_search<S: AsRef<str>>(&self, query: S) -> Result<MusicSearchResult, Error> {
/// This is a generic implementation which casts items to the given type or filters let query = query.as_ref();
/// them out. let context = self.get_context(ClientType::DesktopMusic, true, None).await;
pub async fn music_search<T: FromYtItem, S: AsRef<str>>( let request_body = QSearch {
context,
query,
params: None,
};
self.execute_request::<response::MusicSearch, _, _>(
ClientType::DesktopMusic,
"music_search",
query,
"search",
&request_body,
)
.await
}
/// Search YouTube Music tracks
pub async fn music_search_tracks<S: AsRef<str>>(
&self, &self,
query: S, query: S,
filter: Option<MusicSearchFilter>, ) -> Result<MusicSearchFiltered<TrackItem>, Error> {
) -> Result<MusicSearchResult<T>, Error> { self._music_search_tracks(query, Params::Tracks).await
}
/// Search YouTube Music videos
pub async fn music_search_videos<S: AsRef<str>>(
&self,
query: S,
) -> Result<MusicSearchFiltered<TrackItem>, Error> {
self._music_search_tracks(query, Params::Videos).await
}
async fn _music_search_tracks<S: AsRef<str>>(
&self,
query: S,
params: Params,
) -> Result<MusicSearchFiltered<TrackItem>, Error> {
let query = query.as_ref(); let query = query.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QSearch { let request_body = QSearch {
context,
query, query,
params: filter.map(MusicSearchFilter::params), params: Some(params),
}; };
self.execute_request::<response::MusicSearch, _, _>( self.execute_request::<response::MusicSearch, _, _>(
@ -57,87 +108,111 @@ impl RustyPipeQuery {
.await .await
} }
/// Search YouTube Music and return items of all types
pub async fn music_search_main<S: AsRef<str>>(
&self,
query: S,
) -> Result<MusicSearchResult<MusicItem>, Error> {
self.music_search(query, None).await
}
/// Search YouTube Music artists
pub async fn music_search_artists<S: AsRef<str>>(
&self,
query: S,
) -> Result<MusicSearchResult<ArtistItem>, Error> {
self.music_search(query, Some(MusicSearchFilter::Artists))
.await
}
/// Search YouTube Music albums /// Search YouTube Music albums
pub async fn music_search_albums<S: AsRef<str>>( pub async fn music_search_albums<S: AsRef<str>>(
&self, &self,
query: S, query: S,
) -> Result<MusicSearchResult<AlbumItem>, Error> { ) -> Result<MusicSearchFiltered<AlbumItem>, Error> {
self.music_search(query, Some(MusicSearchFilter::Albums)) let query = query.as_ref();
.await let context = self.get_context(ClientType::DesktopMusic, true, None).await;
} let request_body = QSearch {
context,
/// Search YouTube Music tracks
pub async fn music_search_tracks<S: AsRef<str>>(
&self,
query: S,
) -> Result<MusicSearchResult<TrackItem>, Error> {
self.music_search(query, Some(MusicSearchFilter::Tracks))
.await
}
/// Search YouTube Music videos
pub async fn music_search_videos<S: AsRef<str>>(
&self,
query: S,
) -> Result<MusicSearchResult<TrackItem>, Error> {
self.music_search(query, Some(MusicSearchFilter::Videos))
.await
}
/// Search YouTube Music playlists
///
/// Playlists are filtered whether they are created by users
/// (`community=true`) or by YouTube Music (`community=false`)
pub async fn music_search_playlists<S: AsRef<str> + Debug>(
&self,
query: S,
community: bool,
) -> Result<MusicSearchResult<MusicPlaylistItem>, Error> {
self.music_search(
query, query,
Some(if community { params: Some(Params::Albums),
MusicSearchFilter::CommunityPlaylists };
} else {
MusicSearchFilter::YtmPlaylists self.execute_request::<response::MusicSearch, _, _>(
}), ClientType::DesktopMusic,
"music_search_albums",
query,
"search",
&request_body,
) )
.await .await
} }
/// Search YouTube Music users /// Search YouTube Music artists
pub async fn music_search_users<S: AsRef<str>>( pub async fn music_search_artists(
&self,
query: &str,
) -> Result<MusicSearchFiltered<ArtistItem>, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QSearch {
context,
query,
params: Some(Params::Artists),
};
self.execute_request::<response::MusicSearch, _, _>(
ClientType::DesktopMusic,
"music_search_albums",
query,
"search",
&request_body,
)
.await
}
/// Search YouTube Music playlists
pub async fn music_search_playlists<S: AsRef<str>>(
&self, &self,
query: S, query: S,
) -> Result<MusicSearchResult<UserItem>, Error> { ) -> Result<MusicSearchFiltered<MusicPlaylistItem>, Error> {
self.music_search(query, Some(MusicSearchFilter::Users)) self._music_search_playlists(query, Params::Playlists).await
}
/// Search YouTube Music playlists that were created by users
/// (`community=true`) or by YouTube Music (`community=false`)
pub async fn music_search_playlists_filter<S: AsRef<str>>(
&self,
query: S,
community: bool,
) -> Result<MusicSearchFiltered<MusicPlaylistItem>, Error> {
self._music_search_playlists(
query,
if community {
Params::CommunityPlaylists
} else {
Params::YtmPlaylists
},
)
.await
}
async fn _music_search_playlists<S: AsRef<str>>(
&self,
query: S,
params: Params,
) -> Result<MusicSearchFiltered<MusicPlaylistItem>, Error> {
let query = query.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QSearch {
context,
query,
params: Some(params),
};
self.execute_request::<response::MusicSearch, _, _>(
ClientType::DesktopMusic,
"music_search_playlists",
query,
"search",
&request_body,
)
.await .await
} }
/// Get YouTube Music search suggestions /// Get YouTube Music search suggestions
#[tracing::instrument(skip(self), level = "error")] pub async fn music_search_suggestion<S: AsRef<str>>(
pub async fn music_search_suggestion<S: AsRef<str> + Debug>(
&self, &self,
query: S, query: S,
) -> Result<MusicSearchSuggestion, Error> { ) -> Result<MusicSearchSuggestion, Error> {
let query = query.as_ref(); let query = query.as_ref();
let request_body = QSearchSuggestion { input: query }; let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QSearchSuggestion {
context,
input: query,
};
self.execute_request::<response::MusicSearchSuggestion, _, _>( self.execute_request::<response::MusicSearchSuggestion, _, _>(
ClientType::DesktopMusic, ClientType::DesktopMusic,
@ -150,11 +225,78 @@ impl RustyPipeQuery {
} }
} }
impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch { impl MapResponse<MusicSearchResult> for response::MusicSearch {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, _id: &str,
) -> Result<MapResult<MusicSearchResult<T>>, ExtractionError> { lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicSearchResult>, crate::error::ExtractionError> {
// dbg!(&self);
let sections = self
.contents
.tabbed_search_results_renderer
.contents
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no tab")))?
.tab_renderer
.content
.section_list_renderer
.contents;
let mut corrected_query = None;
let mut order = Vec::new();
let mut mapper = MusicListMapper::new(lang);
sections.into_iter().for_each(|section| match section {
response::music_search::ItemSection::MusicShelfRenderer(shelf) => {
if let Some(etype) = mapper.map_response(shelf.contents) {
if !order.contains(&etype) {
order.push(etype);
}
}
}
response::music_search::ItemSection::MusicCardShelfRenderer(card) => {
if let Some(etype) = mapper.map_card(card) {
if !order.contains(&etype) {
order.push(etype);
}
}
}
response::music_search::ItemSection::ItemSectionRenderer { contents } => {
if let Some(corrected) = contents.into_iter().next() {
corrected_query = Some(corrected.showing_results_for_renderer.corrected_query);
}
}
response::music_search::ItemSection::None => {}
});
mapper.check_unknown()?;
let map_res = mapper.group_items();
Ok(MapResult {
c: MusicSearchResult {
tracks: map_res.c.tracks,
albums: map_res.c.albums,
artists: map_res.c.artists,
playlists: map_res.c.playlists,
corrected_query,
order,
},
warnings: map_res.warnings,
})
}
}
impl<T: FromYtItem> MapResponse<MusicSearchFiltered<T>> for response::MusicSearch {
fn map_response(
self,
_id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicSearchFiltered<T>>, ExtractionError> {
// dbg!(&self); // dbg!(&self);
let tabs = self.contents.tabbed_search_results_renderer.contents; let tabs = self.contents.tabbed_search_results_renderer.contents;
@ -169,7 +311,7 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch
let mut corrected_query = None; let mut corrected_query = None;
let mut ctoken = None; let mut ctoken = None;
let mut mapper = MusicListMapper::new(ctx.lang); let mut mapper = MusicListMapper::new(lang);
sections.into_iter().for_each(|section| match section { sections.into_iter().for_each(|section| match section {
response::music_search::ItemSection::MusicShelfRenderer(shelf) => { response::music_search::ItemSection::MusicShelfRenderer(shelf) => {
@ -189,18 +331,17 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch
response::music_search::ItemSection::None => {} response::music_search::ItemSection::None => {}
}); });
let ctoken = ctoken.or(mapper.ctoken.clone()); mapper.check_unknown()?;
let map_res = mapper.conv_items(); let map_res = mapper.conv_items();
Ok(MapResult { Ok(MapResult {
c: MusicSearchResult { c: MusicSearchFiltered {
items: Paginator::new_ext( items: Paginator::new_ext(
None, None,
map_res.c, map_res.c,
ctoken, ctoken,
ctx.visitor_data.map(str::to_owned), None,
ContinuationEndpoint::MusicSearch, crate::model::paginator::ContinuationEndpoint::MusicSearch,
false,
), ),
corrected_query, corrected_query,
}, },
@ -212,9 +353,11 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch
impl MapResponse<MusicSearchSuggestion> for response::MusicSearchSuggestion { impl MapResponse<MusicSearchSuggestion> for response::MusicSearchSuggestion {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, _id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicSearchSuggestion>, ExtractionError> { ) -> Result<MapResult<MusicSearchSuggestion>, ExtractionError> {
let mut mapper = MusicListMapper::new_search_suggest(ctx.lang); let mut mapper = MusicListMapper::new(lang);
let mut terms = Vec::new(); let mut terms = Vec::new();
for section in self.contents { for section in self.contents {
@ -233,6 +376,7 @@ impl MapResponse<MusicSearchSuggestion> for response::MusicSearchSuggestion {
} }
} }
mapper.check_unknown()?;
let map_res = mapper.conv_items(); let map_res = mapper.conv_items();
Ok(MapResult { Ok(MapResult {
@ -253,11 +397,12 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use crate::{ use crate::{
client::{response, MapRespCtx, MapResponse}, client::{response, MapResponse},
model::{ model::{
AlbumItem, ArtistItem, MusicItem, MusicPlaylistItem, MusicSearchResult, AlbumItem, ArtistItem, MusicPlaylistItem, MusicSearchFiltered, MusicSearchResult,
MusicSearchSuggestion, TrackItem, MusicSearchSuggestion, TrackItem,
}, },
param::Language,
serializer::MapResult, serializer::MapResult,
util::tests::TESTFILES, util::tests::TESTFILES,
}; };
@ -267,15 +412,14 @@ mod tests {
#[case::typo("typo")] #[case::typo("typo")]
#[case::radio("radio")] #[case::radio("radio")]
#[case::artist("artist")] #[case::artist("artist")]
#[case::live("live")]
fn map_music_search_main(#[case] name: &str) { fn map_music_search_main(#[case] name: &str) {
let json_path = path!(*TESTFILES / "music_search" / format!("main_{name}.json")); let json_path = path!(*TESTFILES / "music_search" / format!("main_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let search: response::MusicSearch = let search: response::MusicSearch =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicSearchResult<MusicItem>> = let map_res: MapResult<MusicSearchResult> =
search.map_response(&MapRespCtx::test("")).unwrap(); search.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -297,8 +441,8 @@ mod tests {
let search: response::MusicSearch = let search: response::MusicSearch =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicSearchResult<TrackItem>> = let map_res: MapResult<MusicSearchFiltered<TrackItem>> =
search.map_response(&MapRespCtx::test("")).unwrap(); search.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -316,8 +460,8 @@ mod tests {
let search: response::MusicSearch = let search: response::MusicSearch =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicSearchResult<AlbumItem>> = let map_res: MapResult<MusicSearchFiltered<AlbumItem>> =
search.map_response(&MapRespCtx::test("")).unwrap(); search.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -335,8 +479,8 @@ mod tests {
let search: response::MusicSearch = let search: response::MusicSearch =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicSearchResult<ArtistItem>> = let map_res: MapResult<MusicSearchFiltered<ArtistItem>> =
search.map_response(&MapRespCtx::test("")).unwrap(); search.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -356,8 +500,8 @@ mod tests {
let search: response::MusicSearch = let search: response::MusicSearch =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicSearchResult<MusicPlaylistItem>> = let map_res: MapResult<MusicSearchFiltered<MusicPlaylistItem>> =
search.map_response(&MapRespCtx::test("")).unwrap(); search.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -378,7 +522,7 @@ mod tests {
let suggestion: response::MusicSearchSuggestion = let suggestion: response::MusicSearchSuggestion =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicSearchSuggestion> = let map_res: MapResult<MusicSearchSuggestion> =
suggestion.map_response(&MapRespCtx::test("")).unwrap(); suggestion.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -1,228 +0,0 @@
use std::fmt::Debug;
use crate::{
client::{
response::{self, music_item::MusicListMapper},
ClientType, MapResponse, QBrowseParams, RustyPipeQuery,
},
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
AlbumItem, ArtistItem, HistoryItem, MusicPlaylist, MusicPlaylistItem, TrackItem,
},
serializer::MapResult,
};
use super::{MapRespCtx, MapRespOptions, QContinuation};
impl RustyPipeQuery {
/// Get a list of tracks from YouTube Music which the current user recently played
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_history(&self) -> Result<Paginator<HistoryItem<TrackItem>>, Error> {
let request_body = QBrowseParams {
browse_id: "FEmusic_history",
params: "oggECgIIAQ%3D%3D",
};
self.clone()
.authenticated()
.execute_request::<response::MusicHistory, _, _>(
ClientType::DesktopMusic,
"music_history",
"",
"browse",
&request_body,
)
.await
}
/// Get more YouTube Music history items from the given continuation token
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_history_continuation<S: AsRef<str> + Debug>(
&self,
ctoken: S,
visitor_data: Option<&str>,
) -> Result<Paginator<HistoryItem<TrackItem>>, Error> {
let ctoken = ctoken.as_ref();
let request_body = QContinuation {
continuation: ctoken,
};
self.clone()
.authenticated()
.execute_request_ctx::<response::MusicContinuation, _, _>(
ClientType::Desktop,
"history_continuation",
ctoken,
"browse",
&request_body,
MapRespOptions {
visitor_data,
..Default::default()
},
)
.await
}
/// Get a list of YouTube Music artists which the current user subscribed to
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_artists(&self) -> Result<Paginator<ArtistItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIyEh5GRW11c2ljX2xpYnJhcnlfY29ycHVzX2FydGlzdHMaEGdnTUdLZ1FJQUJBQm9BWUI%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.await
}
/// Get a list of YouTube Music albums which the current user has added to their collection
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_albums(&self) -> Result<Paginator<AlbumItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIoEhRGRW11c2ljX2xpa2VkX2FsYnVtcxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.await
}
/// Get a list of YouTube Music tracks which the current user has added to their collection
///
/// Contains both liked tracks and tracks from saved albums.
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_tracks(&self) -> Result<Paginator<TrackItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIoEhRGRW11c2ljX2xpa2VkX3ZpZGVvcxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.await
}
/// Get a list of YouTube Music playlists which the current user has added to their collection
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_playlists(&self) -> Result<Paginator<MusicPlaylistItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.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 {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> {
let contents = match self.contents {
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => {
c.contents
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData("no content".into()))?
.tab_renderer
.content
.section_list_renderer
}
response::music_playlist::Contents::TwoColumnBrowseResultsRenderer {
secondary_contents,
..
} => secondary_contents.section_list_renderer,
};
let mut map_res = MapResult::default();
for shelf in contents.contents {
let shelf = if let response::music_item::ItemSection::MusicShelfRenderer(s) = shelf {
s
} else {
continue;
};
let mut mapper = MusicListMapper::new(ctx.lang);
mapper.map_response(shelf.contents);
mapper.conv_history_items(shelf.title, ctx.utc_offset, &mut map_res);
}
let ctoken = contents
.continuations
.into_iter()
.next()
.map(|c| c.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
true,
),
warnings: map_res.warnings,
})
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
use path_macro::path;
use crate::util::tests::TESTFILES;
use super::*;
#[test]
fn map_history() {
let json_path = path!(*TESTFILES / "music_userdata" / "music_history.json");
let json_file = File::open(json_path).unwrap();
let history: response::MusicHistory =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = history.map_response(&MapRespCtx::test("")).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(map_res.c, {
".items[].playback_date" => "[date]",
});
}
}

View file

@ -1,5 +1,3 @@
use std::fmt::Debug;
use crate::error::{Error, ExtractionError}; use crate::error::{Error, ExtractionError};
use crate::model::{ use crate::model::{
paginator::{ContinuationEndpoint, Paginator}, paginator::{ContinuationEndpoint, Paginator},
@ -8,21 +6,12 @@ use crate::model::{
}; };
use crate::serializer::MapResult; use crate::serializer::MapResult;
#[cfg(feature = "userdata")] use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
use crate::model::{HistoryItem, TrackItem, VideoItem}; use super::{response, ClientType, MapResponse, QContinuation, RustyPipeQuery};
use super::response::{
music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo},
YouTubeListItem,
};
use super::{
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
};
impl RustyPipeQuery { impl RustyPipeQuery {
/// Get more YouTube items from the given continuation token and endpoint /// Get more YouTube items from the given continuation token and endpoint
#[tracing::instrument(skip(self), level = "error")] pub async fn continuation<T: FromYtItem, S: AsRef<str>>(
pub async fn continuation<T: FromYtItem, S: AsRef<str> + Debug>(
&self, &self,
ctoken: S, ctoken: S,
endpoint: ContinuationEndpoint, endpoint: ContinuationEndpoint,
@ -30,79 +19,85 @@ impl RustyPipeQuery {
) -> Result<Paginator<T>, Error> { ) -> Result<Paginator<T>, Error> {
let ctoken = ctoken.as_ref(); let ctoken = ctoken.as_ref();
if endpoint.is_music() { if endpoint.is_music() {
let context = self
.get_context(ClientType::DesktopMusic, true, visitor_data)
.await;
let request_body = QContinuation { let request_body = QContinuation {
context,
continuation: ctoken, continuation: ctoken,
}; };
let p = self let p = self
.execute_request_ctx::<response::MusicContinuation, Paginator<MusicItem>, _>( .execute_request::<response::MusicContinuation, Paginator<MusicItem>, _>(
ClientType::DesktopMusic, ClientType::DesktopMusic,
"music_continuation", "music_continuation",
ctoken, ctoken,
endpoint.as_str(), endpoint.as_str(),
&request_body, &request_body,
MapRespOptions {
visitor_data,
..Default::default()
},
) )
.await?; .await?;
Ok(map_ytm_paginator(p, endpoint)) Ok(map_ytm_paginator(p, visitor_data, endpoint))
} else { } else {
let context = self
.get_context(ClientType::Desktop, true, visitor_data)
.await;
let request_body = QContinuation { let request_body = QContinuation {
context,
continuation: ctoken, continuation: ctoken,
}; };
let p = self let p = self
.execute_request_ctx::<response::Continuation, Paginator<YouTubeItem>, _>( .execute_request::<response::Continuation, Paginator<YouTubeItem>, _>(
ClientType::Desktop, ClientType::Desktop,
"continuation", "continuation",
ctoken, ctoken,
endpoint.as_str(), endpoint.as_str(),
&request_body, &request_body,
MapRespOptions {
visitor_data,
..Default::default()
},
) )
.await?; .await?;
Ok(map_yt_paginator(p, endpoint)) Ok(map_yt_paginator(p, visitor_data, endpoint))
} }
} }
} }
fn map_yt_paginator<T: FromYtItem>( fn map_yt_paginator<T: FromYtItem>(
p: Paginator<YouTubeItem>, p: Paginator<YouTubeItem>,
visitor_data: Option<&str>,
endpoint: ContinuationEndpoint, endpoint: ContinuationEndpoint,
) -> Paginator<T> { ) -> Paginator<T> {
Paginator { Paginator {
count: p.count, count: p.count,
items: p.items.into_iter().filter_map(T::from_yt_item).collect(), items: p.items.into_iter().filter_map(T::from_yt_item).collect(),
ctoken: p.ctoken, ctoken: p.ctoken,
visitor_data: p.visitor_data, visitor_data: visitor_data.map(str::to_owned),
endpoint, endpoint,
authenticated: p.authenticated,
} }
} }
fn map_ytm_paginator<T: FromYtItem>( fn map_ytm_paginator<T: FromYtItem>(
p: Paginator<MusicItem>, p: Paginator<MusicItem>,
visitor_data: Option<&str>,
endpoint: ContinuationEndpoint, endpoint: ContinuationEndpoint,
) -> Paginator<T> { ) -> Paginator<T> {
Paginator { Paginator {
count: p.count, count: p.count,
items: p.items.into_iter().filter_map(T::from_ytm_item).collect(), items: p.items.into_iter().filter_map(T::from_ytm_item).collect(),
ctoken: p.ctoken, ctoken: p.ctoken,
visitor_data: p.visitor_data, visitor_data: visitor_data.map(str::to_owned),
endpoint, endpoint,
authenticated: p.authenticated,
} }
} }
fn continuation_items(response: response::Continuation) -> MapResult<Vec<YouTubeListItem>> { impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
response fn map_response(
self,
_id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> {
let items = self
.on_response_received_actions .on_response_received_actions
.and_then(|actions| { .and_then(|actions| {
actions actions
@ -115,33 +110,16 @@ fn continuation_items(response: response::Continuation) -> MapResult<Vec<YouTube
}) })
}) })
.or_else(|| { .or_else(|| {
response self.continuation_contents
.continuation_contents
.map(|contents| contents.rich_grid_continuation.contents) .map(|contents| contents.rich_grid_continuation.contents)
}) })
.unwrap_or_default() .unwrap_or_default();
}
impl MapResponse<Paginator<YouTubeItem>> for response::Continuation { let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> {
let estimated_results = self.estimated_results;
let items = continuation_items(self);
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
mapper.map_response(items); mapper.map_response(items);
Ok(MapResult { Ok(MapResult {
c: Paginator::new_ext( c: Paginator::new(self.estimated_results, mapper.items, mapper.ctoken),
estimated_results,
mapper.items,
mapper.ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::Browse,
ctx.authenticated,
),
warnings: mapper.warnings, warnings: mapper.warnings,
}) })
} }
@ -150,13 +128,11 @@ impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation { impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, _id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Paginator<MusicItem>>, ExtractionError> { ) -> Result<MapResult<Paginator<MusicItem>>, ExtractionError> {
let mut mapper = if let Some(artist) = &ctx.artist { let mut mapper = MusicListMapper::new(lang);
MusicListMapper::with_artist(ctx.lang, artist.clone())
} else {
MusicListMapper::new(ctx.lang)
};
let mut continuations = Vec::new(); let mut continuations = Vec::new();
match self.continuation_contents { match self.continuation_contents {
@ -174,11 +150,7 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
mapper.map_response(shelf.contents); mapper.map_response(shelf.contents);
} }
response::music_item::ItemSection::GridRenderer(mut grid) => { _ => {}
mapper.map_response(grid.items);
continuations.append(&mut grid.continuations);
}
response::music_item::ItemSection::None => {}
} }
} }
} }
@ -189,135 +161,23 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
mapper.add_warnings(&mut panel.contents.warnings); mapper.add_warnings(&mut panel.contents.warnings);
panel.contents.c.into_iter().for_each(|item| { panel.contents.c.into_iter().for_each(|item| {
if let PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) = item { if let PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) = item {
let mut track = map_queue_item(item, ctx.lang); let mut track = map_queue_item(item, lang);
mapper.add_item(MusicItem::Track(track.c)); mapper.add_item(MusicItem::Track(track.c));
mapper.add_warnings(&mut track.warnings); mapper.add_warnings(&mut track.warnings);
} }
}); });
} }
Some(response::music_item::ContinuationContents::GridContinuation(mut grid)) => {
mapper.map_response(grid.items);
continuations.append(&mut grid.continuations);
}
None => {} None => {}
} }
for a in self.on_response_received_actions {
mapper.map_response(a.append_continuation_items_action.continuation_items);
}
let ctoken = mapper.ctoken.clone().or_else(|| {
continuations
.into_iter()
.next()
.map(|cont| cont.next_continuation_data.continuation)
});
let map_res = mapper.items(); let map_res = mapper.items();
Ok(MapResult {
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
),
warnings: map_res.warnings,
})
}
}
#[cfg(feature = "userdata")]
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<HistoryItem<VideoItem>>>, ExtractionError> {
let mut map_res = MapResult::default();
let mut ctoken = None;
let items = continuation_items(self);
for item in items.c {
match item {
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
mapper.map_response(contents);
mapper.conv_history_items(
header.map(|h| h.item_section_header_renderer.title),
ctx.utc_offset,
&mut map_res,
);
}
response::YouTubeListItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
if ctoken.is_none() {
ctoken = Some(continuation_endpoint.continuation_command.token);
}
}
_ => {}
}
}
Ok(MapResult {
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::Browse,
ctx.authenticated,
),
warnings: map_res.warnings,
})
}
}
#[cfg(feature = "userdata")]
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicContinuation {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> {
let mut map_res = MapResult::default();
let mut continuations = Vec::new();
let mut map_shelf = |shelf: response::music_item::MusicShelf| {
let mut mapper = MusicListMapper::new(ctx.lang);
mapper.map_response(shelf.contents);
mapper.conv_history_items(shelf.title, ctx.utc_offset, &mut map_res);
continuations.extend(shelf.continuations);
};
match self.continuation_contents {
Some(response::music_item::ContinuationContents::MusicShelfContinuation(shelf)) => {
map_shelf(shelf);
}
Some(response::music_item::ContinuationContents::SectionListContinuation(contents)) => {
for c in contents.contents {
if let response::music_item::ItemSection::MusicShelfRenderer(shelf) = c {
map_shelf(shelf);
}
}
}
_ => {}
}
let ctoken = continuations let ctoken = continuations
.into_iter() .into_iter()
.next() .next()
.map(|cont| cont.next_continuation_data.continuation); .map(|cont| cont.next_continuation_data.continuation);
Ok(MapResult { Ok(MapResult {
c: Paginator::new_ext( c: Paginator::new(None, map_res.c, ctoken),
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
),
warnings: map_res.warnings, warnings: map_res.warnings,
}) })
} }
@ -327,18 +187,12 @@ impl<T: FromYtItem> Paginator<T> {
/// 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> {
Ok(match &self.ctoken { Ok(match &self.ctoken {
Some(ctoken) => { Some(ctoken) => Some(
let q = if self.authenticated { query
&query.as_ref().clone().authenticated() .as_ref()
} else { .continuation(ctoken, self.endpoint, self.visitor_data.as_deref())
query.as_ref()
};
Some(
q.continuation(ctoken, self.endpoint, self.visitor_data.as_deref())
.await?, .await?,
) ),
}
_ => None, _ => None,
}) })
} }
@ -352,9 +206,6 @@ impl<T: FromYtItem> Paginator<T> {
let mut items = paginator.items; let mut items = paginator.items;
self.items.append(&mut items); self.items.append(&mut items);
self.ctoken = paginator.ctoken; self.ctoken = paginator.ctoken;
if paginator.visitor_data.is_some() {
self.visitor_data = paginator.visitor_data;
}
Ok(true) Ok(true)
} }
Ok(None) => Ok(false), Ok(None) => Ok(false),
@ -397,19 +248,6 @@ impl<T: FromYtItem> Paginator<T> {
} }
Ok(()) Ok(())
} }
/// Extend the items of the paginator until the paginator is exhausted.
pub async fn extend_all<Q: AsRef<RustyPipeQuery>>(&mut self, query: Q) -> Result<(), Error> {
let query = query.as_ref();
loop {
match self.extend(query).await {
Ok(false) => break,
Err(e) => return Err(e),
_ => {}
}
}
Ok(())
}
} }
impl Paginator<Comment> { impl Paginator<Comment> {
@ -427,40 +265,6 @@ impl Paginator<Comment> {
} }
} }
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
impl Paginator<HistoryItem<VideoItem>> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
Ok(match &self.ctoken {
Some(ctoken) => Some(
query
.as_ref()
.history_continuation(ctoken, self.visitor_data.as_deref())
.await?,
),
_ => None,
})
}
}
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
impl Paginator<HistoryItem<TrackItem>> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
Ok(match &self.ctoken {
Some(ctoken) => Some(
query
.as_ref()
.music_history_continuation(ctoken, self.visitor_data.as_deref())
.await?,
),
_ => None,
})
}
}
macro_rules! paginator { macro_rules! paginator {
($entity_type:ty) => { ($entity_type:ty) => {
impl Paginator<$entity_type> { impl Paginator<$entity_type> {
@ -476,9 +280,6 @@ macro_rules! paginator {
let mut items = paginator.items; let mut items = paginator.items;
self.items.append(&mut items); self.items.append(&mut items);
self.ctoken = paginator.ctoken; self.ctoken = paginator.ctoken;
if paginator.visitor_data.is_some() {
self.visitor_data = paginator.visitor_data;
}
Ok(true) Ok(true)
} }
Ok(None) => Ok(false), Ok(None) => Ok(false),
@ -521,33 +322,11 @@ macro_rules! paginator {
} }
Ok(()) Ok(())
} }
/// Extend the items of the paginator until the paginator is exhausted.
pub async fn extend_all<Q: AsRef<RustyPipeQuery>>(
&mut self,
query: Q,
) -> Result<(), Error> {
let query = query.as_ref();
loop {
match self.extend(query).await {
Ok(false) => break,
Err(e) => return Err(e),
_ => {}
}
}
Ok(())
}
} }
}; };
} }
paginator!(Comment); paginator!(Comment);
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
paginator!(HistoryItem<VideoItem>);
#[cfg(feature = "userdata")]
#[cfg_attr(docsrs, doc(cfg(feature = "userdata")))]
paginator!(HistoryItem<TrackItem>);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@ -558,15 +337,14 @@ mod tests {
use super::*; use super::*;
use crate::{ use crate::{
model::{ model::{MusicPlaylistItem, PlaylistItem, TrackItem, VideoItem},
AlbumItem, ArtistItem, ChannelItem, MusicPlaylistItem, PlaylistItem, TrackItem, param::Language,
VideoItem,
},
util::tests::TESTFILES, util::tests::TESTFILES,
}; };
#[rstest] #[rstest]
#[case::search("search", path!("search" / "cont.json"))] #[case::search("search", path!("search" / "cont.json"))]
#[case::startpage("startpage", path!("trends" / "startpage_cont.json"))]
#[case::recommendations("recommendations", path!("video_details" / "recommendations.json"))] #[case::recommendations("recommendations", path!("video_details" / "recommendations.json"))]
fn map_continuation_items(#[case] name: &str, #[case] path: PathBuf) { fn map_continuation_items(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path); let json_path = path!(*TESTFILES / path);
@ -575,7 +353,7 @@ mod tests {
let items: response::Continuation = let items: response::Continuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<YouTubeItem>> = let map_res: MapResult<Paginator<YouTubeItem>> =
items.map_response(&MapRespCtx::test("")).unwrap(); items.map_response("", Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -597,9 +375,9 @@ mod tests {
let items: response::Continuation = let items: response::Continuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<YouTubeItem>> = let map_res: MapResult<Paginator<YouTubeItem>> =
items.map_response(&MapRespCtx::test("")).unwrap(); items.map_response("", Language::En, None).unwrap();
let paginator: Paginator<VideoItem> = let paginator: Paginator<VideoItem> =
map_yt_paginator(map_res.c, ContinuationEndpoint::Browse); map_yt_paginator(map_res.c, None, ContinuationEndpoint::Browse);
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -620,30 +398,9 @@ mod tests {
let items: response::Continuation = let items: response::Continuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<YouTubeItem>> = let map_res: MapResult<Paginator<YouTubeItem>> =
items.map_response(&MapRespCtx::test("")).unwrap(); items.map_response("", Language::En, None).unwrap();
let paginator: Paginator<PlaylistItem> = let paginator: Paginator<PlaylistItem> =
map_yt_paginator(map_res.c, ContinuationEndpoint::Browse); map_yt_paginator(map_res.c, None, ContinuationEndpoint::Browse);
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::subscriptions("subscriptions", path!("userdata" / "subscriptions.json"))]
fn map_continuation_channels(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
let items: response::Continuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<YouTubeItem>> =
items.map_response(&MapRespCtx::test("")).unwrap();
let paginator: Paginator<ChannelItem> =
map_yt_paginator(map_res.c, ContinuationEndpoint::Browse);
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -657,7 +414,6 @@ 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_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,51 +421,9 @@ mod tests {
let items: response::MusicContinuation = let items: response::MusicContinuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<MusicItem>> = let map_res: MapResult<Paginator<MusicItem>> =
items.map_response(&MapRespCtx::test("")).unwrap(); items.map_response("", Language::En, None).unwrap();
let paginator: Paginator<TrackItem> = let paginator: Paginator<TrackItem> =
map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse); map_ytm_paginator(map_res.c, None, ContinuationEndpoint::MusicBrowse);
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::saved_artists("saved_artists", path!("music_userdata" / "saved_artists.json"))]
fn map_continuation_artists(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
let items: response::MusicContinuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<MusicItem>> =
items.map_response(&MapRespCtx::test("")).unwrap();
let paginator: Paginator<ArtistItem> =
map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse);
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::saved_albums("saved_albums", path!("music_userdata" / "saved_albums.json"))]
fn map_continuation_albums(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
let items: response::MusicContinuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<MusicItem>> =
items.map_response(&MapRespCtx::test("")).unwrap();
let paginator: Paginator<AlbumItem> =
map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse);
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),
@ -721,7 +435,6 @@ 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_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();
@ -729,9 +442,9 @@ mod tests {
let items: response::MusicContinuation = let items: response::MusicContinuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<MusicItem>> = let map_res: MapResult<Paginator<MusicItem>> =
items.map_response(&MapRespCtx::test("")).unwrap(); items.map_response("", Language::En, None).unwrap();
let paginator: Paginator<MusicPlaylistItem> = let paginator: Paginator<MusicPlaylistItem> =
map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse); map_ytm_paginator(map_res.c, None, ContinuationEndpoint::MusicBrowse);
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,22 @@
use std::{borrow::Cow, convert::TryFrom, fmt::Debug}; use std::{borrow::Cow, convert::TryFrom};
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{ use crate::{
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{paginator::Paginator, ChannelId, Playlist, VideoItem},
paginator::{ContinuationEndpoint, Paginator}, util::{self, timeago, TryRemove},
richtext::RichText,
ChannelId, Playlist, VideoItem,
},
serializer::text::{TextComponent, TextComponents},
util::{self, dictionary, timeago, TryRemove},
}; };
use super::{response, ClientType, MapRespCtx, MapResponse, MapResult, QBrowse, RustyPipeQuery}; use super::{response, ClientType, MapResponse, MapResult, QBrowse, RustyPipeQuery};
impl RustyPipeQuery { impl RustyPipeQuery {
/// Get a YouTube playlist /// Get a YouTube playlist
#[tracing::instrument(skip(self), level = "error")] pub async fn playlist<S: AsRef<str>>(&self, playlist_id: S) -> Result<Playlist, Error> {
pub async fn playlist<S: AsRef<str> + Debug>(&self, playlist_id: S) -> Result<Playlist, Error> {
let playlist_id = playlist_id.as_ref(); let playlist_id = playlist_id.as_ref();
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QBrowse { let request_body = QBrowse {
context,
browse_id: &format!("VL{playlist_id}"), browse_id: &format!("VL{playlist_id}"),
}; };
@ -36,9 +32,14 @@ impl RustyPipeQuery {
} }
impl MapResponse<Playlist> for response::Playlist { impl MapResponse<Playlist> for response::Playlist {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<Playlist>, ExtractionError> { fn map_response(
self,
id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Playlist>, ExtractionError> {
let (Some(contents), Some(header)) = (self.contents, self.header) else { let (Some(contents), Some(header)) = (self.contents, self.header) else {
return Err(response::alerts_to_err(ctx.id, self.alerts)); return Err(response::alerts_to_err(id, self.alerts));
}; };
let video_items = contents let video_items = contents
@ -68,10 +69,10 @@ impl MapResponse<Playlist> for response::Playlist {
.playlist_video_list_renderer .playlist_video_list_renderer
.contents; .contents;
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang); let mut mapper = response::YouTubeListMapper::<VideoItem>::new(lang);
mapper.map_response(video_items); mapper.map_response(video_items);
let (description, thumbnails, last_update_txt) = match self.sidebar { let (thumbnails, last_update_txt) = match self.sidebar {
Some(sidebar) => { Some(sidebar) => {
let sidebar_items = sidebar.playlist_sidebar_renderer.contents; let sidebar_items = sidebar.playlist_sidebar_renderer.contents;
let mut primary = let mut primary =
@ -83,132 +84,60 @@ impl MapResponse<Playlist> for response::Playlist {
)))?; )))?;
( (
primary
.playlist_sidebar_primary_info_renderer
.description
.filter(|d| !d.0.is_empty()),
Some(
primary primary
.playlist_sidebar_primary_info_renderer .playlist_sidebar_primary_info_renderer
.thumbnail_renderer .thumbnail_renderer
.playlist_video_thumbnail_renderer .playlist_video_thumbnail_renderer
.thumbnail, .thumbnail,
),
primary primary
.playlist_sidebar_primary_info_renderer .playlist_sidebar_primary_info_renderer
.stats .stats
.try_swap_remove(2), .try_swap_remove(2),
) )
} }
None => (None, None, None), None => {
}; let header_banner = header
.playlist_header_renderer
.playlist_header_banner
.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
"no thumbnail found",
)))?;
let (name, playlist_id, channel, n_videos_txt, description2, thumbnails2, last_update_txt2) = let mut byline = header.playlist_header_renderer.byline;
match header {
response::playlist::Header::PlaylistHeaderRenderer(header_renderer) => {
let mut byline = header_renderer.byline;
let last_update_txt = byline let last_update_txt = byline
.try_swap_remove(1) .try_swap_remove(1)
.map(|b| b.playlist_byline_renderer.text); .map(|b| b.playlist_byline_renderer.text);
( (
header_renderer.title, header_banner.hero_playlist_thumbnail_renderer.thumbnail,
header_renderer.playlist_id,
header_renderer
.owner_text
.and_then(|link| ChannelId::try_from(link).ok()),
header_renderer.num_videos_text,
header_renderer
.description_text
.map(|text| TextComponents(vec![TextComponent::new(text)])),
header_renderer
.playlist_header_banner
.map(|b| b.hero_playlist_thumbnail_renderer.thumbnail),
last_update_txt, last_update_txt,
) )
} }
response::playlist::Header::PageHeaderRenderer(content_renderer) => {
let h = content_renderer.content.page_header_view_model;
let rows = h.metadata.content_metadata_view_model.metadata_rows;
let n_videos_txt = rows
.get(1)
.and_then(|r| r.metadata_parts.get(1))
.map(|p| p.as_str().to_owned())
.ok_or(ExtractionError::InvalidData("no video count".into()))?;
let mut channel = rows
.into_iter()
.next()
.and_then(|r| r.metadata_parts.into_iter().next())
.and_then(|p| match p {
response::MetadataPart::Text(_) => None,
response::MetadataPart::AvatarStack {
avatar_stack_view_model,
} => ChannelId::try_from(avatar_stack_view_model.text).ok(),
});
// remove "by" prefix
if let Some(c) = channel.as_mut() {
let entry = dictionary::entry(ctx.lang);
let n = c.name.strip_prefix(entry.chan_prefix).unwrap_or(&c.name);
let n = n.strip_suffix(entry.chan_suffix).unwrap_or(n);
c.name = n.trim().to_owned();
}
let playlist_id = h
.actions
.flexible_actions_view_model
.actions_rows
.into_iter()
.next()
.and_then(|r| r.actions.into_iter().next())
.and_then(|a| {
a.button_view_model
.on_tap
.innertube_command
.into_playlist_id()
})
.ok_or(ExtractionError::InvalidData("no playlist id".into()))?;
(
h.title.dynamic_text_view_model.text,
playlist_id,
channel,
n_videos_txt,
h.description.description_preview_view_model.description,
h.hero_image.content_preview_image_view_model.image.into(),
None,
)
}
}; };
let n_videos = if mapper.ctoken.is_some() { let n_videos = if mapper.ctoken.is_some() {
util::parse_numeric(&n_videos_txt) util::parse_numeric(&header.playlist_header_renderer.num_videos_text)
.map_err(|_| ExtractionError::InvalidData("no video count".into()))? .map_err(|_| ExtractionError::InvalidData(Cow::Borrowed("no video count")))?
} else { } else {
mapper.items.len() as u64 mapper.items.len() as u64
}; };
if playlist_id != ctx.id { let playlist_id = header.playlist_header_renderer.playlist_id;
if playlist_id != id {
return Err(ExtractionError::WrongResult(format!( return Err(ExtractionError::WrongResult(format!(
"got wrong playlist id {}, expected {}", "got wrong playlist id {playlist_id}, expected {id}"
playlist_id, ctx.id
))); )));
} }
let description = description.or(description2).map(RichText::from); let name = header.playlist_header_renderer.title;
let thumbnails = thumbnails let description = header.playlist_header_renderer.description_text;
.or(thumbnails2) let channel = header
.ok_or(ExtractionError::InvalidData(Cow::Borrowed( .playlist_header_renderer
"no thumbnail found", .owner_text
)))?; .and_then(|link| ChannelId::try_from(link).ok());
let last_update = last_update_txt
.as_deref() let last_update = last_update_txt.as_ref().and_then(|txt| {
.or(last_update_txt2.as_deref()) timeago::parse_textual_date_or_warn(lang, txt, &mut mapper.warnings)
.and_then(|txt| {
timeago::parse_textual_date_or_warn(
ctx.lang,
ctx.utc_offset,
txt,
&mut mapper.warnings,
)
.map(OffsetDateTime::date) .map(OffsetDateTime::date)
}); });
@ -216,24 +145,14 @@ impl MapResponse<Playlist> for response::Playlist {
c: Playlist { c: Playlist {
id: playlist_id, id: playlist_id,
name, name,
videos: Paginator::new_ext( videos: Paginator::new(Some(n_videos), mapper.items, mapper.ctoken),
Some(n_videos),
mapper.items,
mapper.ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::Browse,
ctx.authenticated,
),
video_count: n_videos, video_count: n_videos,
thumbnail: thumbnails.into(), thumbnail: thumbnails.into(),
description, description,
channel, channel,
last_update, last_update,
last_update_txt, last_update_txt,
visitor_data: self visitor_data: self.response_context.visitor_data,
.response_context
.visitor_data
.or_else(|| ctx.visitor_data.map(str::to_owned)),
}, },
warnings: mapper.warnings, warnings: mapper.warnings,
}) })
@ -247,7 +166,7 @@ mod tests {
use path_macro::path; use path_macro::path;
use rstest::rstest; use rstest::rstest;
use crate::util::tests::TESTFILES; use crate::{param::Language, util::tests::TESTFILES};
use super::*; use super::*;
@ -256,14 +175,13 @@ mod tests {
#[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")]
#[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
#[case::live("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw")] #[case::live("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw")]
#[case::pageheader("20241011_pageheader", "PLT2w2oBf1TZKyvY_M6JsASs73m-wjLzH5")]
fn map_playlist_data(#[case] name: &str, #[case] id: &str) { fn map_playlist_data(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json")); let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let playlist: response::Playlist = let playlist: response::Playlist =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = playlist.map_response(&MapRespCtx::test(id)).unwrap(); let map_res = playlist.map_response(id, Language::En, None).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -2,14 +2,10 @@ 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 super::{ use super::{
video_item::YouTubeListRenderer, Alert, AttachmentRun, AvatarViewModel, ChannelBadge, video_item::YouTubeListRenderer, Alert, ChannelBadge, ContentsRenderer, ResponseContext,
ContentRenderer, ContentsRenderer, ContinuationActionWrap, ImageView, Thumbnails, TwoColumnBrowseResults,
PageHeaderRendererContent, PhMetadataView, ResponseContext, Thumbnails, TwoColumnBrowseResults,
};
use crate::{
model::Verification,
serializer::text::{AttributedText, Text, TextComponent},
}; };
use crate::serializer::text::Text;
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -40,7 +36,7 @@ pub(crate) struct TabRendererWrap {
pub(crate) struct TabRenderer { pub(crate) struct TabRenderer {
#[serde(default)] #[serde(default)]
pub content: TabContent, pub content: TabContent,
pub endpoint: Option<ChannelTabEndpoint>, pub endpoint: ChannelTabEndpoint,
} }
#[serde_as] #[serde_as]
@ -75,12 +71,10 @@ pub(crate) struct ChannelTabWebCommandMetadata {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[allow(clippy::enum_variant_names)]
pub(crate) enum Header { pub(crate) enum Header {
C4TabbedHeaderRenderer(HeaderRenderer), C4TabbedHeaderRenderer(HeaderRenderer),
/// Used for special channels like YouTube Music /// Used for special channels like YouTube Music
CarouselHeaderRenderer(ContentsRenderer<CarouselHeaderRendererItem>), CarouselHeaderRenderer(ContentsRenderer<CarouselHeaderRendererItem>),
PageHeaderRenderer(ContentRenderer<PageHeaderRendererContent<PageHeaderRendererInner>>),
} }
#[serde_as] #[serde_as]
@ -99,6 +93,11 @@ pub(crate) struct HeaderRenderer {
pub badges: Vec<ChannelBadge>, pub badges: Vec<ChannelBadge>,
#[serde(default)] #[serde(default)]
pub banner: Thumbnails, pub banner: Thumbnails,
#[serde(default)]
pub mobile_banner: Thumbnails,
/// Fullscreen (16:9) channel banner
#[serde(default)]
pub tv_banner: Thumbnails,
} }
#[serde_as] #[serde_as]
@ -118,59 +117,6 @@ pub(crate) enum CarouselHeaderRendererItem {
None, None,
} }
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PageHeaderRendererInner {
/// Channel title (only used to extract verification badges)
#[serde_as(as = "DefaultOnError")]
pub title: Option<PhTitleView>,
/// Channel avatar
pub image: PhAvatarView,
/// Channel metadata (subscribers, video count)
pub metadata: PhMetadataView,
#[serde(default)]
pub banner: PhBannerView,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhTitleView {
pub dynamic_text_view_model: PhTitleView2,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhTitleView2 {
pub text: PhTitleView3,
}
#[serde_as]
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhTitleView3 {
#[serde_as(as = "VecSkipError<_>")]
pub attachment_runs: Vec<AttachmentRun>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhAvatarView {
pub decorated_avatar_view_model: PhAvatarView2,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhAvatarView2 {
pub avatar: AvatarViewModel,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhBannerView {
pub image_banner_view_model: ImageView,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Metadata { pub(crate) struct Metadata {
@ -199,85 +145,3 @@ pub(crate) struct MicroformatDataRenderer {
#[serde(default)] #[serde(default)]
pub tags: Vec<String>, pub tags: Vec<String>,
} }
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ChannelAbout {
#[serde(rename_all = "camelCase")]
ReceivedEndpoints {
#[serde_as(as = "VecSkipError<_>")]
on_response_received_endpoints: Vec<ContinuationActionWrap<AboutChannelRendererWrap>>,
},
Content {
contents: Option<Contents>,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AboutChannelRendererWrap {
pub about_channel_renderer: AboutChannelRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AboutChannelRenderer {
pub metadata: ChannelMetadata,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelMetadata {
pub about_channel_view_model: ChannelMetadataView,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelMetadataView {
pub channel_id: String,
pub canonical_channel_url: String,
pub country: Option<String>,
#[serde(default)]
pub description: String,
#[serde_as(as = "Option<Text>")]
pub joined_date_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub subscriber_count_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub video_count_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>,
#[serde(default)]
pub links: Vec<ExternalLink>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalLink {
pub channel_external_link_view_model: ExternalLinkInner,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalLinkInner {
#[serde_as(as = "AttributedText")]
pub title: TextComponent,
#[serde_as(as = "AttributedText")]
pub link: TextComponent,
}
impl From<PhTitleView> for crate::model::Verification {
fn from(value: PhTitleView) -> Self {
value
.dynamic_text_view_model
.text
.attachment_runs
.into_iter()
.next()
.map(Verification::from)
.unwrap_or_default()
}
}

View file

@ -1,6 +1,8 @@
use serde::Deserialize; use serde::Deserialize;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::util;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct ChannelRss { pub(crate) struct ChannelRss {
#[serde(rename = "channelId")] #[serde(rename = "channelId")]
@ -78,3 +80,52 @@ impl From<Thumbnail> for crate::model::Thumbnail {
} }
} }
} }
impl From<ChannelRss> for crate::model::ChannelRss {
fn from(feed: ChannelRss) -> Self {
let id = if feed.channel_id.is_empty() {
feed.entry
.iter()
.find_map(|entry| {
Some(entry.channel_id.as_str())
.filter(|id| id.is_empty())
.map(str::to_owned)
})
.or_else(|| {
feed.author
.uri
.strip_prefix("https://www.youtube.com/channel/")
.and_then(|id| {
if util::CHANNEL_ID_REGEX.is_match(id) {
Some(id.to_owned())
} else {
None
}
})
})
.unwrap_or_default()
} else {
feed.channel_id
};
Self {
id,
name: feed.title,
videos: feed
.entry
.into_iter()
.map(|item| crate::model::ChannelRssVideo {
id: item.video_id,
name: item.title,
description: item.media_group.description,
thumbnail: item.media_group.thumbnail.into(),
publish_date: item.published,
update_date: item.updated,
view_count: item.media_group.community.statistics.views,
like_count: item.media_group.community.rating.count,
})
.collect(),
create_date: feed.create_date,
}
}
}

View file

@ -1,8 +0,0 @@
use serde::Deserialize;
use super::{video_item::YouTubeListRendererWrap, Tab, TwoColumnBrowseResults};
#[derive(Debug, Deserialize)]
pub(crate) struct History {
pub contents: TwoColumnBrowseResults<Tab<YouTubeListRendererWrap>>,
}

View file

@ -16,7 +16,6 @@ pub(crate) mod video_details;
pub(crate) mod video_item; pub(crate) mod video_item;
pub(crate) use channel::Channel; pub(crate) use channel::Channel;
pub(crate) use channel::ChannelAbout;
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;
@ -30,11 +29,11 @@ pub(crate) use music_new::MusicNew;
pub(crate) use music_playlist::MusicPlaylist; pub(crate) use music_playlist::MusicPlaylist;
pub(crate) use music_search::MusicSearch; pub(crate) use music_search::MusicSearch;
pub(crate) use music_search::MusicSearchSuggestion; pub(crate) use music_search::MusicSearchSuggestion;
pub(crate) use player::DrmLicense;
pub(crate) use player::Player; pub(crate) use player::Player;
pub(crate) use playlist::Playlist; pub(crate) use playlist::Playlist;
pub(crate) use search::Search; pub(crate) use search::Search;
pub(crate) use search::SearchSuggestion; pub(crate) use search::SearchSuggestion;
pub(crate) use trends::Startpage;
pub(crate) use trends::Trending; pub(crate) use trends::Trending;
pub(crate) use url_endpoint::ResolvedUrl; pub(crate) use url_endpoint::ResolvedUrl;
pub(crate) use video_details::VideoComments; pub(crate) use video_details::VideoComments;
@ -47,28 +46,17 @@ 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::marker::PhantomData; use std::marker::PhantomData;
use serde::{ use serde::{
de::{IgnoredAny, Visitor}, de::{IgnoredAny, Visitor},
Deserialize, Deserialize,
}; };
use serde_with::{serde_as, DisplayFromStr, VecSkipError}; use serde_with::{json::JsonString, serde_as, VecSkipError};
use crate::error::ExtractionError; use crate::error::ExtractionError;
use crate::serializer::text::{AttributedText, Text, TextComponent}; use crate::serializer::{text::Text, MapResult, VecSkipErrorWrap};
use crate::serializer::{MapResult, VecSkipErrorWrap};
use self::video_item::YouTubeListRenderer; use self::video_item::YouTubeListRenderer;
@ -78,9 +66,6 @@ pub(crate) struct ContentRenderer<T> {
pub content: T, pub content: T,
} }
/// Deserializes any object with an array field named `contents`, `tabs` or `items`.
///
/// Invalid items are skipped
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct ContentsRenderer<T> { pub(crate) struct ContentsRenderer<T> {
pub contents: Vec<T>, pub contents: Vec<T>,
@ -117,24 +102,12 @@ pub(crate) struct ThumbnailsWrap {
pub thumbnail: Thumbnails, pub thumbnail: Thumbnails,
} }
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ImageView {
pub image: Thumbnails,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AvatarViewModel {
pub avatar_view_model: ImageView,
}
/// List of images in different resolutions. /// List of images in different resolutions.
/// Not only used for thumbnails, but also for avatars and banners. /// Not only used for thumbnails, but also for avatars and banners.
#[derive(Default, Debug, Deserialize)] #[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Thumbnails { pub(crate) struct Thumbnails {
#[serde(default, alias = "sources")] #[serde(default)]
pub thumbnails: Vec<Thumbnail>, pub thumbnails: Vec<Thumbnail>,
} }
@ -204,92 +177,23 @@ pub(crate) enum ChannelBadgeStyle {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Alert { pub(crate) struct Alert {
pub alert_renderer: TextBox, pub alert_renderer: AlertRenderer,
} }
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct TextBox { pub(crate) struct AlertRenderer {
#[serde_as(as = "Text")] #[serde_as(as = "Text")]
pub text: String, pub text: String,
} }
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SimpleHeaderRenderer {
#[serde_as(as = "Text")]
pub title: String,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TextComponentBox {
#[serde_as(as = "AttributedText")]
pub text: TextComponent,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct ResponseContext { pub(crate) struct ResponseContext {
pub visitor_data: Option<String>, pub visitor_data: Option<String>,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachmentRun {
pub element: AttachmentRunElement,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachmentRunElement {
#[serde(rename = "type")]
pub typ: AttachmentRunElementType,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachmentRunElementType {
pub image_type: AttachmentRunElementImageType,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachmentRunElementImageType {
pub image: AttachmentRunElementImage,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachmentRunElementImage {
#[serde_as(as = "VecSkipError<_>")]
pub sources: Vec<AttachmentRunElementImageSource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachmentRunElementImageSource {
pub client_resource: ClientResource,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientResource {
pub image_name: IconName,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum IconName {
CheckCircleFilled,
#[serde(alias = "AUDIO_BADGE")]
MusicFilled,
}
// CONTINUATION // CONTINUATION
#[serde_as] #[serde_as]
@ -297,14 +201,14 @@ pub enum IconName {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Continuation { pub(crate) struct Continuation {
/// Number of search results /// Number of search results
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<JsonString>")]
pub estimated_results: Option<u64>, pub estimated_results: Option<u64>,
#[serde( #[serde(
alias = "onResponseReceivedCommands", alias = "onResponseReceivedCommands",
alias = "onResponseReceivedEndpoints" alias = "onResponseReceivedEndpoints"
)] )]
#[serde_as(as = "Option<VecSkipError<_>>")] #[serde_as(as = "Option<VecSkipError<_>>")]
pub on_response_received_actions: Option<Vec<ContinuationActionWrap<YouTubeListItem>>>, pub on_response_received_actions: Option<Vec<ContinuationActionWrap>>,
/// Used for channel video rich grid renderer /// Used for channel video rich grid renderer
/// ///
/// A/B test seen on 19.10.2022 /// A/B test seen on 19.10.2022
@ -313,15 +217,15 @@ pub(crate) struct Continuation {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationActionWrap<T> { pub(crate) struct ContinuationActionWrap {
#[serde(alias = "reloadContinuationItemsCommand")] #[serde(alias = "reloadContinuationItemsCommand")]
pub append_continuation_items_action: ContinuationAction<T>, pub append_continuation_items_action: ContinuationAction,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationAction<T> { pub(crate) struct ContinuationAction {
pub continuation_items: MapResult<Vec<T>>, pub continuation_items: MapResult<Vec<YouTubeListItem>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -428,22 +332,9 @@ impl From<Thumbnails> for Vec<crate::model::Thumbnail> {
} }
} }
impl ContentImage {
pub(crate) fn into_image(self) -> ImageViewOl {
match self {
ContentImage::ThumbnailViewModel(image) => image,
ContentImage::CollectionThumbnailViewModel { primary_thumbnail } => {
primary_thumbnail.thumbnail_view_model
}
}
}
}
impl From<Vec<ChannelBadge>> for crate::model::Verification { impl From<Vec<ChannelBadge>> for crate::model::Verification {
fn from(badges: Vec<ChannelBadge>) -> Self { fn from(badges: Vec<ChannelBadge>) -> Self {
badges badges.get(0).map_or(crate::model::Verification::None, |b| {
.first()
.map_or(crate::model::Verification::None, |b| {
match b.metadata_badge_renderer.style { match b.metadata_badge_renderer.style {
ChannelBadgeStyle::BadgeStyleTypeVerified => Self::Verified, ChannelBadgeStyle::BadgeStyleTypeVerified => Self::Verified,
ChannelBadgeStyle::BadgeStyleTypeVerifiedArtist => Self::Artist, ChannelBadgeStyle::BadgeStyleTypeVerifiedArtist => Self::Artist,
@ -462,25 +353,6 @@ impl From<Icon> for crate::model::Verification {
} }
} }
impl From<AttachmentRun> for crate::model::Verification {
fn from(value: AttachmentRun) -> Self {
match value
.element
.typ
.image_type
.image
.sources
.into_iter()
.next()
.map(|s| s.client_resource.image_name)
{
Some(IconName::CheckCircleFilled) => Self::Verified,
Some(IconName::MusicFilled) => Self::Artist,
None => Self::None,
}
}
}
pub(crate) fn alerts_to_err(id: &str, alerts: Option<Vec<Alert>>) -> ExtractionError { pub(crate) fn alerts_to_err(id: &str, alerts: Option<Vec<Alert>>) -> ExtractionError {
ExtractionError::NotFound { ExtractionError::NotFound {
id: id.to_owned(), id: id.to_owned(),
@ -496,196 +368,3 @@ pub(crate) fn alerts_to_err(id: &str, alerts: Option<Vec<Alert>>) -> ExtractionE
.unwrap_or_default(), .unwrap_or_default(),
} }
} }
// FRAMEWORK UPDATES
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FrameworkUpdates<T> {
pub entity_batch_update: EntityBatchUpdate<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EntityBatchUpdate<T> {
pub mutations: FrameworkUpdateMutations<T>,
}
/// List of update mutations that deserializes into a HashMap (entity_key => payload)
#[derive(Debug)]
pub(crate) struct FrameworkUpdateMutations<T> {
pub items: HashMap<String, T>,
pub warnings: Vec<String>,
}
impl<'de, T> Deserialize<'de> for FrameworkUpdateMutations<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SeqVisitor<T>(PhantomData<T>);
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum MutationOrError<T> {
#[serde(rename_all = "camelCase")]
Good {
entity_key: String,
payload: T,
},
Error(serde_json::Value),
}
impl<'de, T> Visitor<'de> for SeqVisitor<T>
where
T: Deserialize<'de>,
{
type Value = FrameworkUpdateMutations<T>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("sequence of entity mutations")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut items = HashMap::with_capacity(seq.size_hint().unwrap_or_default());
let mut warnings = Vec::new();
while let Some(value) = seq.next_element::<MutationOrError<T>>()? {
match value {
MutationOrError::Good {
entity_key,
payload,
} => {
items.insert(entity_key, payload);
}
MutationOrError::Error(value) => {
warnings.push(format!(
"error deserializing item: {}",
serde_json::to_string(&value).unwrap_or_default()
));
}
}
}
Ok(FrameworkUpdateMutations { items, warnings })
}
}
deserializer.deserialize_seq(SeqVisitor(PhantomData::<T>))
}
}
// PAGE HEADER
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PageHeaderRendererContent<T> {
pub page_header_view_model: T,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhMetadataView {
pub content_metadata_view_model: PhMetadataView2,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhMetadataView2 {
#[serde_as(as = "VecSkipError<_>")]
pub metadata_rows: Vec<PhMetadataRow>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhMetadataRow {
#[serde_as(as = "VecSkipError<_>")]
pub metadata_parts: Vec<MetadataPart>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum MetadataPart {
Text(#[serde_as(as = "AttributedText")] TextComponent),
#[serde(rename_all = "camelCase")]
AvatarStack {
avatar_stack_view_model: TextComponentBox,
},
}
impl MetadataPart {
pub fn into_text_component(self) -> TextComponent {
match self {
MetadataPart::Text(text_component) => text_component,
MetadataPart::AvatarStack {
avatar_stack_view_model,
} => avatar_stack_view_model.text,
}
}
pub fn as_str(&self) -> &str {
match self {
MetadataPart::Text(s) => s.as_str(),
MetadataPart::AvatarStack {
avatar_stack_view_model,
} => avatar_stack_view_model.text.as_str(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum ContentImage {
ThumbnailViewModel(ImageViewOl),
#[serde(rename_all = "camelCase")]
CollectionThumbnailViewModel {
primary_thumbnail: ThumbnailViewModelWrap,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ThumbnailViewModelWrap {
pub thumbnail_view_model: ImageViewOl,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ImageViewOl {
pub image: Thumbnails,
#[serde_as(as = "VecSkipError<_>")]
pub overlays: Vec<ImageViewOverlay>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ImageViewOverlay {
pub thumbnail_overlay_badge_view_model: ThumbnailOverlayBadgeViewModel,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ThumbnailOverlayBadgeViewModel {
#[serde_as(as = "VecSkipError<_>")]
pub thumbnail_badges: Vec<ThumbnailBadges>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ThumbnailBadges {
pub thumbnail_badge_view_model: TextBox,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Empty {}

View file

@ -14,7 +14,7 @@ use super::{
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct MusicArtist { pub(crate) struct MusicArtist {
pub contents: SingleColumnBrowseResult<Tab<SectionList<ItemSection>>>, pub contents: SingleColumnBrowseResult<Tab<Option<SectionList<ItemSection>>>>,
pub header: Header, pub header: Header,
} }

View file

@ -1,13 +1,14 @@
use serde::Deserialize; use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError, VecSkipError}; use serde_with::serde_as;
use serde_with::DefaultOnError;
use crate::serializer::text::Text; use crate::serializer::text::Text;
use super::AlertRenderer;
use super::ContentsRenderer; use super::ContentsRenderer;
use super::TextBox;
use super::{ use super::{
music_item::{ItemSection, PlaylistPanelRenderer}, music_item::{ItemSection, PlaylistPanelRenderer},
ContentRenderer, ContentRenderer, SectionList,
}; };
/// Response model for YouTube Music track details /// Response model for YouTube Music track details
@ -35,11 +36,9 @@ pub(crate) struct TabbedRenderer {
pub watch_next_tabbed_results_renderer: TabbedRendererInner, pub watch_next_tabbed_results_renderer: TabbedRendererInner,
} }
#[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct TabbedRendererInner { pub(crate) struct TabbedRendererInner {
#[serde_as(as = "VecSkipError<_>")]
pub tabs: Vec<Tab>, pub tabs: Vec<Tab>,
} }
@ -108,14 +107,14 @@ pub(crate) struct PlaylistPanel {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct MusicLyrics { pub(crate) struct MusicLyrics {
pub contents: ListOrMessage<LyricsSection>, pub contents: LyricsContents,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) enum ListOrMessage<T> { pub(crate) struct LyricsContents {
SectionListRenderer(ContentsRenderer<T>), pub message_renderer: Option<AlertRenderer>,
MessageRenderer(TextBox), pub section_list_renderer: Option<ContentsRenderer<LyricsSection>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -137,14 +136,5 @@ pub(crate) struct LyricsRenderer {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct MusicRelated { pub(crate) struct MusicRelated {
pub contents: ListOrMessage<ItemSection>, pub contents: SectionList<ItemSection>,
}
impl<T> ListOrMessage<T> {
pub fn into_res(self) -> Result<Vec<T>, String> {
match self {
ListOrMessage::SectionListRenderer(c) => Ok(c.contents),
ListOrMessage::MessageRenderer(msg) => Err(msg.text),
}
}
} }

View file

@ -1,8 +0,0 @@
use serde::Deserialize;
use super::music_playlist::Contents;
#[derive(Debug, Deserialize)]
pub(crate) struct MusicHistory {
pub contents: Contents,
}

File diff suppressed because it is too large Load diff

View file

@ -1,46 +1,27 @@
use serde::Deserialize; use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError, VecSkipError}; use serde_with::{serde_as, DefaultOnError, VecSkipError};
use crate::serializer::text::{AttributedText, Text, TextComponents}; use crate::serializer::text::{Text, TextComponents};
use super::{ use super::{
music_item::{ music_item::{
Button, ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicThumbnailRenderer, ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicThumbnailRenderer,
SingleColumnBrowseResult,
}, },
url_endpoint::OnTapWrap, Tab,
ContentsRenderer, SectionList, Tab,
}; };
/// Response model for YouTube Music playlists and albums /// Response model for YouTube Music playlists and albums
#[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct MusicPlaylist { pub(crate) struct MusicPlaylist {
pub contents: Contents, pub contents: SingleColumnBrowseResult<Tab<SectionList>>,
pub header: Option<Header>, pub header: Option<Header>,
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub microformat: Option<Microformat>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum Contents {
SingleColumnBrowseResultsRenderer(ContentsRenderer<Tab<PlSectionList>>),
#[serde(rename_all = "camelCase")]
TwoColumnBrowseResultsRenderer {
/// List content
secondary_contents: PlSectionList,
/// Header
#[serde_as(as = "VecSkipError<_>")]
tabs: Vec<Tab<SectionList<Header>>>,
},
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct PlSectionList { pub(crate) struct SectionList {
/// Includes a continuation token for fetching recommendations /// Includes a continuation token for fetching recommendations
pub section_list_renderer: MusicContentsRenderer<ItemSection>, pub section_list_renderer: MusicContentsRenderer<ItemSection>,
} }
@ -48,7 +29,6 @@ pub(crate) struct PlSectionList {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Header { pub(crate) struct Header {
#[serde(alias = "musicResponsiveHeaderRenderer")]
pub music_detail_header_renderer: HeaderRenderer, pub music_detail_header_renderer: HeaderRenderer,
} }
@ -68,13 +48,12 @@ pub(crate) struct HeaderRenderer {
pub subtitle: TextComponents, pub subtitle: TextComponents,
/// Playlist/album description. May contain hashtags which are /// Playlist/album description. May contain hashtags which are
/// displayed as search links on the YouTube website. /// displayed as search links on the YouTube website.
pub description: Option<Description>, #[serde_as(as = "Option<Text>")]
pub description: Option<String>,
/// Playlist thumbnail / album cover. /// Playlist thumbnail / album cover.
/// Missing on artist_tracks view. /// Missing on artist_tracks view.
#[serde(default)] #[serde(default)]
pub thumbnail: MusicThumbnailRenderer, pub thumbnail: MusicThumbnailRenderer,
/// Channel (only on TwoColumnBrowseResultsRenderer)
pub strapline_text_one: Option<TextComponents>,
/// Number of tracks + playtime. /// Number of tracks + playtime.
/// Missing on artist_tracks view. /// Missing on artist_tracks view.
/// ///
@ -84,32 +63,9 @@ pub(crate) struct HeaderRenderer {
#[serde(default)] #[serde(default)]
#[serde_as(as = "Text")] #[serde_as(as = "Text")]
pub second_subtitle: Vec<String>, pub second_subtitle: Vec<String>,
/// Channel (newer data model)
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub facepile: Option<AvatarStackViewModelWrap>,
#[serde(default)] #[serde(default)]
#[serde_as(as = "DefaultOnError")] #[serde_as(as = "DefaultOnError")]
pub menu: Option<HeaderMenu>, pub menu: Option<HeaderMenu>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub buttons: Vec<HeaderMenu>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum Description {
#[serde(rename_all = "camelCase")]
Shelf {
music_description_shelf_renderer: DescriptionShelf,
},
Text(TextComponents),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DescriptionShelf {
pub description: TextComponents,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -124,53 +80,31 @@ pub(crate) struct HeaderMenu {
pub(crate) struct HeaderMenuRenderer { pub(crate) struct HeaderMenuRenderer {
#[serde(default)] #[serde(default)]
#[serde_as(as = "VecSkipError<_>")] #[serde_as(as = "VecSkipError<_>")]
pub top_level_buttons: Vec<Button>, pub top_level_buttons: Vec<TopLevelButton>,
#[serde_as(as = "VecSkipError<_>")] #[serde_as(as = "VecSkipError<_>")]
pub items: Vec<MusicItemMenuEntry>, pub items: Vec<MusicItemMenuEntry>,
} }
impl From<Description> for TextComponents { #[derive(Debug, Deserialize)]
fn from(value: Description) -> Self { #[serde(rename_all = "camelCase")]
match value { pub(crate) struct TopLevelButton {
Description::Text(v) => v, pub button_renderer: ButtonRenderer,
Description::Shelf {
music_description_shelf_renderer,
} => music_description_shelf_renderer.description,
}
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct AvatarStackViewModelWrap { pub(crate) struct ButtonRenderer {
pub avatar_stack_view_model: AvatarStackViewModel, pub navigation_endpoint: PlaylistEndpoint,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AvatarStackViewModel {
// #[serde(default)]
// pub avatars: Vec<AvatarViewModel>,
#[serde_as(as = "AttributedText")]
pub text: String,
pub renderer_context: AvatarStackRendererContext,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct AvatarStackRendererContext { pub(crate) struct PlaylistEndpoint {
pub command_context: Option<OnTapWrap>, pub watch_playlist_endpoint: PlaylistWatchEndpoint,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Microformat { pub(crate) struct PlaylistWatchEndpoint {
pub microformat_data_renderer: MicroformatData, pub playlist_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MicroformatData {
pub url_canonical: String,
} }

View file

@ -2,9 +2,9 @@ use std::ops::Range;
use serde::Deserialize; use serde::Deserialize;
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::{DefaultOnError, DisplayFromStr, VecSkipError}; use serde_with::{json::JsonString, DefaultOnError};
use super::{Empty, ResponseContext, Thumbnails}; use super::{ResponseContext, Thumbnails};
use crate::serializer::{text::Text, MapResult}; use crate::serializer::{text::Text, MapResult};
#[serde_as] #[serde_as]
@ -19,10 +19,6 @@ pub(crate) struct Player {
#[serde_as(deserialize_as = "DefaultOnError")] #[serde_as(deserialize_as = "DefaultOnError")]
pub storyboards: Option<Storyboards>, pub storyboards: Option<Storyboards>,
pub response_context: ResponseContext, pub response_context: ResponseContext,
#[serde(default)]
pub player_config: PlayerConfig,
#[serde(default)]
pub heartbeat_params: HeartbeatParams,
} }
#[serde_as] #[serde_as]
@ -61,6 +57,9 @@ pub(crate) enum PlayabilityStatus {
}, },
} }
#[derive(Debug, Deserialize)]
pub(crate) struct Empty {}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct ErrorScreen { pub(crate) struct ErrorScreen {
@ -79,7 +78,7 @@ pub(crate) struct ErrorMessage {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct StreamingData { pub(crate) struct StreamingData {
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "JsonString")]
pub expires_in_seconds: u32, pub expires_in_seconds: u32,
#[serde(default)] #[serde(default)]
pub formats: MapResult<Vec<Format>>, pub formats: MapResult<Vec<Format>>,
@ -89,10 +88,6 @@ pub(crate) struct StreamingData {
pub dash_manifest_url: Option<String>, pub dash_manifest_url: Option<String>,
/// Only on livestreams /// Only on livestreams
pub hls_manifest_url: Option<String>, pub hls_manifest_url: Option<String>,
pub drm_params: Option<String>,
#[serde(default)]
#[serde_as(deserialize_as = "VecSkipError<_>")]
pub initial_authorized_drm_track_types: Vec<DrmTrackType>,
} }
#[serde_as] #[serde_as]
@ -111,7 +106,7 @@ pub(crate) struct Format {
pub width: Option<u32>, pub width: Option<u32>,
pub height: Option<u32>, pub height: Option<u32>,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<JsonString>")]
pub approx_duration_ms: Option<u32>, pub approx_duration_ms: Option<u32>,
#[serde_as(as = "Option<crate::serializer::Range>")] #[serde_as(as = "Option<crate::serializer::Range>")]
@ -119,7 +114,7 @@ pub(crate) struct Format {
#[serde_as(as = "Option<crate::serializer::Range>")] #[serde_as(as = "Option<crate::serializer::Range>")]
pub init_range: Option<Range<u32>>, pub init_range: Option<Range<u32>>,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<JsonString>")]
pub content_length: Option<u64>, pub content_length: Option<u64>,
#[serde(default)] #[serde(default)]
@ -134,23 +129,20 @@ pub(crate) struct Format {
#[serde(default)] #[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")] #[serde_as(deserialize_as = "DefaultOnError")]
pub audio_quality: Option<AudioQuality>, pub audio_quality: Option<AudioQuality>,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<JsonString>")]
pub audio_sample_rate: Option<u32>, pub audio_sample_rate: Option<u32>,
pub audio_channels: Option<u8>, pub audio_channels: Option<u8>,
pub loudness_db: Option<f32>, pub loudness_db: Option<f32>,
pub audio_track: Option<AudioTrack>, pub audio_track: Option<AudioTrack>,
pub signature_cipher: Option<String>, pub signature_cipher: Option<String>,
#[serde(default)]
#[serde_as(deserialize_as = "VecSkipError<_>")]
pub drm_families: Vec<DrmFamily>,
pub drm_track_type: Option<DrmTrackType>,
} }
impl Format { impl Format {
pub fn is_audio(&self) -> bool { pub fn is_audio(&self) -> bool {
self.audio_quality.is_some() && self.audio_sample_rate.is_some() self.content_length.is_some()
&& self.audio_quality.is_some()
&& self.audio_sample_rate.is_some()
} }
pub fn is_video(&self) -> bool { pub fn is_video(&self) -> bool {
@ -162,7 +154,7 @@ impl Format {
} }
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub(crate) enum Quality { pub(crate) enum Quality {
Tiny, Tiny,
@ -176,19 +168,17 @@ pub(crate) enum Quality {
Hd2160, Hd2160,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum AudioQuality { pub(crate) enum AudioQuality {
#[serde(rename = "AUDIO_QUALITY_ULTRALOW")] #[serde(rename = "AUDIO_QUALITY_LOW", alias = "low")]
UltraLow,
#[serde(rename = "AUDIO_QUALITY_LOW")]
Low, Low,
#[serde(rename = "AUDIO_QUALITY_MEDIUM")] #[serde(rename = "AUDIO_QUALITY_MEDIUM", alias = "medium")]
Medium, Medium,
#[serde(rename = "AUDIO_QUALITY_HIGH")] #[serde(rename = "AUDIO_QUALITY_HIGH", alias = "high")]
High, High,
} }
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum FormatType { pub(crate) enum FormatType {
#[default] #[default]
@ -203,7 +193,7 @@ pub(crate) struct ColorInfo {
pub primaries: Primaries, pub primaries: Primaries,
} }
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum Primaries { pub(crate) enum Primaries {
#[default] #[default]
@ -211,24 +201,6 @@ pub(crate) enum Primaries {
ColorPrimariesBt2020, ColorPrimariesBt2020,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[allow(clippy::enum_variant_names)]
pub(crate) enum DrmTrackType {
DrmTrackTypeAudio,
DrmTrackTypeSd,
DrmTrackTypeHd,
DrmTrackTypeUhd1,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum DrmFamily {
Widevine,
Playready,
Fairplay,
}
#[derive(Default, Debug, Deserialize)] #[derive(Default, Debug, Deserialize)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
pub(crate) struct AudioTrack { pub(crate) struct AudioTrack {
@ -264,8 +236,8 @@ pub(crate) struct CaptionTrack {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct VideoDetails { pub(crate) struct VideoDetails {
pub video_id: String, pub video_id: String,
pub title: Option<String>, pub title: String,
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "JsonString")]
pub length_seconds: u32, pub length_seconds: u32,
#[serde(default)] #[serde(default)]
pub keywords: Vec<String>, pub keywords: Vec<String>,
@ -273,9 +245,9 @@ pub(crate) struct VideoDetails {
pub short_description: Option<String>, pub short_description: Option<String>,
#[serde(default)] #[serde(default)]
pub thumbnail: Thumbnails, pub thumbnail: Thumbnails,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "JsonString")]
pub view_count: Option<u64>, pub view_count: u64,
pub author: Option<String>, pub author: String,
pub is_live_content: bool, pub is_live_content: bool,
} }
@ -290,57 +262,3 @@ pub(crate) struct Storyboards {
pub(crate) struct StoryboardRenderer { pub(crate) struct StoryboardRenderer {
pub spec: String, pub spec: String,
} }
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlayerConfig {
pub web_drm_config: Option<WebDrmConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WebDrmConfig {
pub widevine_service_cert: Option<String>,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HeartbeatParams {
pub drm_session_id: Option<String>,
}
impl From<DrmTrackType> for crate::model::DrmTrackType {
fn from(value: DrmTrackType) -> Self {
match value {
DrmTrackType::DrmTrackTypeAudio => Self::Audio,
DrmTrackType::DrmTrackTypeSd => Self::Sd,
DrmTrackType::DrmTrackTypeHd => Self::Hd,
DrmTrackType::DrmTrackTypeUhd1 => Self::Uhd1,
}
}
}
impl From<DrmFamily> for crate::model::DrmSystem {
fn from(value: DrmFamily) -> Self {
match value {
DrmFamily::Widevine => Self::Widevine,
DrmFamily::Playready => Self::Playready,
DrmFamily::Fairplay => Self::Fairplay,
}
}
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DrmLicense {
pub status: String,
pub license: String,
pub authorized_formats: Vec<AuthorizedFormat>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AuthorizedFormat {
pub track_type: DrmTrackType,
pub key_id: String,
}

View file

@ -1,12 +1,11 @@
use serde::Deserialize; use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError, VecSkipError}; use serde_with::{serde_as, DefaultOnError};
use crate::serializer::text::{AttributedText, Text, TextComponent, TextComponents}; use crate::serializer::text::{Text, TextComponent};
use super::{ use super::{
url_endpoint::OnTapWrap, video_item::YouTubeListRenderer, Alert, ContentRenderer, video_item::YouTubeListRenderer, Alert, ContentsRenderer, ResponseContext, SectionList, Tab,
ContentsRenderer, ImageView, PageHeaderRendererContent, PhMetadataView, ResponseContext, ThumbnailsWrap, TwoColumnBrowseResults,
SectionList, Tab, TextBox, ThumbnailsWrap, TwoColumnBrowseResults,
}; };
#[serde_as] #[serde_as]
@ -30,15 +29,13 @@ pub(crate) struct ItemSection {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistVideoListRenderer { pub(crate) struct PlaylistVideoListRenderer {
#[serde(alias = "richGridRenderer")]
pub playlist_video_list_renderer: YouTubeListRenderer, pub playlist_video_list_renderer: YouTubeListRenderer,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) enum Header { pub(crate) struct Header {
PlaylistHeaderRenderer(HeaderRenderer), pub playlist_header_renderer: HeaderRenderer,
PageHeaderRenderer(ContentRenderer<PageHeaderRendererContent<PageHeaderRendererInner>>),
} }
#[serde_as] #[serde_as]
@ -70,7 +67,15 @@ pub(crate) struct PlaylistHeaderBanner {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Byline { pub(crate) struct Byline {
pub playlist_byline_renderer: TextBox, pub playlist_byline_renderer: BylineRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BylineRenderer {
#[serde_as(as = "Text")]
pub text: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -89,7 +94,6 @@ pub(crate) struct SidebarItemPrimary {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct SidebarPrimaryInfoRenderer { pub(crate) struct SidebarPrimaryInfoRenderer {
pub description: Option<TextComponents>,
pub thumbnail_renderer: PlaylistThumbnailRenderer, pub thumbnail_renderer: PlaylistThumbnailRenderer,
/// - `"495", " videos"` /// - `"495", " videos"`
/// - `"3,310,996 views"` /// - `"3,310,996 views"`
@ -105,73 +109,3 @@ pub(crate) struct PlaylistThumbnailRenderer {
#[serde(alias = "playlistCustomThumbnailRenderer")] #[serde(alias = "playlistCustomThumbnailRenderer")]
pub playlist_video_thumbnail_renderer: ThumbnailsWrap, pub playlist_video_thumbnail_renderer: ThumbnailsWrap,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PageHeaderRendererInner {
pub title: PhTitleView,
pub metadata: PhMetadataView,
pub actions: PhActions,
pub description: PhDescription,
pub hero_image: PhHeroImage,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhDescription {
pub description_preview_view_model: PhDescription2,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhDescription2 {
#[serde_as(as = "Option<AttributedText>")]
pub description: Option<TextComponents>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhHeroImage {
pub content_preview_image_view_model: ImageView,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhTitleView {
pub dynamic_text_view_model: PhTitleInner,
}
#[serde_as]
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhTitleInner {
#[serde_as(as = "AttributedText")]
pub text: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhActions {
pub flexible_actions_view_model: PhActions2,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhActions2 {
pub actions_rows: Vec<ActionsRow>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ActionsRow {
#[serde_as(as = "VecSkipError<_>")]
pub actions: Vec<ButtonAction>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ButtonAction {
pub button_view_model: OnTapWrap,
}

View file

@ -2,7 +2,7 @@ use serde::{
de::{IgnoredAny, Visitor}, de::{IgnoredAny, Visitor},
Deserialize, Deserialize,
}; };
use serde_with::{serde_as, DisplayFromStr}; use serde_with::{json::JsonString, serde_as};
use super::{video_item::YouTubeListRendererWrap, ResponseContext}; use super::{video_item::YouTubeListRendererWrap, ResponseContext};
@ -10,7 +10,7 @@ use super::{video_item::YouTubeListRendererWrap, ResponseContext};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Search { pub(crate) struct Search {
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<JsonString>")]
pub estimated_results: Option<u64>, pub estimated_results: Option<u64>,
pub contents: Contents, pub contents: Contents,
pub response_context: ResponseContext, pub response_context: ResponseContext,

View file

@ -1,6 +1,13 @@
use serde::Deserialize; use serde::Deserialize;
use super::{video_item::YouTubeListRendererWrap, Tab, TwoColumnBrowseResults}; use super::{video_item::YouTubeListRendererWrap, ResponseContext, Tab, TwoColumnBrowseResults};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Startpage {
pub contents: Contents,
pub response_context: ResponseContext,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View file

@ -1,12 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError}; use serde_with::{serde_as, DefaultOnError};
use crate::{ use crate::{model::UrlTarget, util};
model::{TrackType, UrlTarget},
util,
};
use super::Empty;
/// navigation/resolve_url response model /// navigation/resolve_url response model
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -16,30 +11,21 @@ pub(crate) struct ResolvedUrl {
} }
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Default)]
#[serde(untagged)]
pub(crate) enum NavigationEndpoint {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
Watch { pub(crate) struct NavigationEndpoint {
#[serde(alias = "reelWatchEndpoint")]
watch_endpoint: WatchEndpoint,
},
#[serde(rename_all = "camelCase")]
Browse {
browse_endpoint: BrowseEndpoint,
#[serde(default)] #[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")] #[serde_as(deserialize_as = "DefaultOnError")]
command_metadata: Option<CommandMetadata>, pub watch_endpoint: Option<WatchEndpoint>,
}, #[serde(default)]
#[serde(rename_all = "camelCase")] #[serde_as(deserialize_as = "DefaultOnError")]
Url { url_endpoint: UrlEndpoint }, pub browse_endpoint: Option<BrowseEndpoint>,
#[serde(rename_all = "camelCase")] #[serde(default)]
WatchPlaylist { #[serde_as(deserialize_as = "DefaultOnError")]
watch_playlist_endpoint: WatchPlaylistEndpoint, pub url_endpoint: Option<UrlEndpoint>,
}, #[serde(default)]
#[serde(rename_all = "camelCase")] #[serde_as(deserialize_as = "DefaultOnError")]
#[allow(unused)] pub command_metadata: Option<CommandMetadata>,
CreatePlaylist { create_playlist_endpoint: Empty },
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -66,12 +52,6 @@ pub(crate) struct BrowseEndpointWrap {
pub browse_endpoint: BrowseEndpoint, pub browse_endpoint: BrowseEndpoint,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WatchPlaylistEndpoint {
pub playlist_id: String,
}
impl<'de> Deserialize<'de> for BrowseEndpoint { impl<'de> Deserialize<'de> for BrowseEndpoint {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
@ -123,12 +103,9 @@ pub(crate) struct BrowseEndpointConfig {
pub browse_endpoint_context_music_config: BrowseEndpointMusicConfig, pub browse_endpoint_context_music_config: BrowseEndpointMusicConfig,
} }
#[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct BrowseEndpointMusicConfig { pub(crate) struct BrowseEndpointMusicConfig {
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub page_type: PageType, pub page_type: PageType,
} }
@ -138,12 +115,9 @@ pub(crate) struct CommandMetadata {
pub web_command_metadata: WebCommandMetadata, pub web_command_metadata: WebCommandMetadata,
} }
#[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct WebCommandMetadata { pub(crate) struct WebCommandMetadata {
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub web_page_type: PageType, pub web_page_type: PageType,
} }
@ -162,60 +136,24 @@ pub(crate) struct WatchEndpointConfig {
pub music_video_type: MusicVideoType, pub music_video_type: MusicVideoType,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OnTap {
pub innertube_command: NavigationEndpoint,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OnTapWrap {
pub on_tap: OnTap,
}
#[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
pub(crate) enum MusicVideoType { pub(crate) enum MusicVideoType {
#[default] #[default]
#[serde(rename = "MUSIC_VIDEO_TYPE_OMV", alias = "MUSIC_VIDEO_TYPE_UGC")] #[serde(rename = "MUSIC_VIDEO_TYPE_OMV")]
Video, Video,
#[serde(rename = "MUSIC_VIDEO_TYPE_ATV")] #[serde(rename = "MUSIC_VIDEO_TYPE_ATV")]
Track, Track,
#[serde(rename = "MUSIC_VIDEO_TYPE_PODCAST_EPISODE")]
Episode,
} }
impl MusicVideoType { #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
pub fn is_video(self) -> bool {
self != Self::Track
}
pub fn from_is_video(is_video: bool) -> Self {
if is_video {
Self::Video
} else {
Self::Track
}
}
}
impl From<MusicVideoType> for TrackType {
fn from(value: MusicVideoType) -> Self {
match value {
MusicVideoType::Video => Self::Video,
MusicVideoType::Track => Self::Track,
MusicVideoType::Episode => Self::Episode,
}
}
}
#[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
pub(crate) enum PageType { pub(crate) enum PageType {
#[serde( #[serde(
rename = "MUSIC_PAGE_TYPE_ARTIST", rename = "MUSIC_PAGE_TYPE_ARTIST",
alias = "MUSIC_PAGE_TYPE_AUDIOBOOK_ARTIST" alias = "MUSIC_PAGE_TYPE_AUDIOBOOK_ARTIST"
)] )]
Artist, Artist,
#[serde(rename = "MUSIC_PAGE_TYPE_ARTIST_DISCOGRAPHY")]
ArtistDiscography,
#[serde(rename = "MUSIC_PAGE_TYPE_ALBUM", alias = "MUSIC_PAGE_TYPE_AUDIOBOOK")] #[serde(rename = "MUSIC_PAGE_TYPE_ALBUM", alias = "MUSIC_PAGE_TYPE_AUDIOBOOK")]
Album, Album,
#[serde( #[serde(
@ -225,11 +163,7 @@ pub(crate) enum PageType {
Channel, Channel,
#[serde(rename = "MUSIC_PAGE_TYPE_PLAYLIST", alias = "WEB_PAGE_TYPE_PLAYLIST")] #[serde(rename = "MUSIC_PAGE_TYPE_PLAYLIST", alias = "WEB_PAGE_TYPE_PLAYLIST")]
Playlist, Playlist,
#[serde(rename = "MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE")] #[serde(rename = "MUSIC_PAGE_TYPE_UNKNOWN")]
Podcast,
#[serde(rename = "MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE")]
Episode,
#[default]
Unknown, Unknown,
} }
@ -237,15 +171,11 @@ impl PageType {
pub(crate) fn to_url_target(self, id: String) -> Option<UrlTarget> { pub(crate) fn to_url_target(self, id: String) -> Option<UrlTarget> {
match self { match self {
PageType::Artist | PageType::Channel => Some(UrlTarget::Channel { id }), PageType::Artist | PageType::Channel => Some(UrlTarget::Channel { id }),
PageType::ArtistDiscography => id
.strip_prefix(util::ARTIST_DISCOGRAPHY_PREFIX)
.map(|id| UrlTarget::Channel { id: id.to_owned() }),
PageType::Album => Some(UrlTarget::Album { id }), PageType::Album => Some(UrlTarget::Album { id }),
PageType::Playlist => Some(UrlTarget::Playlist { id }), PageType::Playlist => Some(UrlTarget::Playlist { id }),
PageType::Podcast => Some(UrlTarget::Playlist {
id: util::strip_prefix(&id, util::PODCAST_PLAYLIST_PREFIX),
}),
PageType::Episode => Some(UrlTarget::Video {
id: util::strip_prefix(&id, util::PODCAST_EPISODE_PREFIX),
start_time: 0,
}),
PageType::Unknown => None, PageType::Unknown => None,
} }
} }
@ -255,9 +185,9 @@ impl PageType {
pub(crate) enum MusicPageType { pub(crate) enum MusicPageType {
Artist, Artist,
Album, Album,
Playlist { is_podcast: bool }, Playlist,
Track { vtype: MusicVideoType }, Track { is_video: bool },
User, Unknown,
None, None,
} }
@ -266,131 +196,46 @@ impl From<PageType> for MusicPageType {
match t { match t {
PageType::Artist => MusicPageType::Artist, PageType::Artist => MusicPageType::Artist,
PageType::Album => MusicPageType::Album, PageType::Album => MusicPageType::Album,
PageType::Playlist => MusicPageType::Playlist { is_podcast: false }, PageType::Playlist => MusicPageType::Playlist,
PageType::Podcast => MusicPageType::Playlist { is_podcast: true }, PageType::Channel | PageType::ArtistDiscography => MusicPageType::None,
PageType::Channel => MusicPageType::User, PageType::Unknown => MusicPageType::Unknown,
PageType::Episode => MusicPageType::Track {
vtype: MusicVideoType::Episode,
},
PageType::Unknown => MusicPageType::None,
}
}
}
pub(crate) struct MusicPage {
pub id: String,
pub typ: MusicPageType,
}
impl MusicPage {
/// Create a new MusicPage object, applying the required ID fixes when
/// mapping a browse link
pub fn from_browse(mut id: String, typ: PageType) -> Self {
if typ == PageType::Podcast {
id = util::strip_prefix(&id, util::PODCAST_PLAYLIST_PREFIX);
} else if typ == PageType::Episode && id.len() == 15 {
id = util::strip_prefix(&id, util::PODCAST_EPISODE_PREFIX);
}
Self {
id,
typ: typ.into(),
} }
} }
} }
impl NavigationEndpoint { impl NavigationEndpoint {
/// Get the YouTube Music page and id from a browse/watch endpoint pub(crate) fn music_page(self) -> Option<(MusicPageType, String)> {
pub(crate) fn music_page(self) -> Option<MusicPage> { self.browse_endpoint
match self { .and_then(|be| {
NavigationEndpoint::Watch { watch_endpoint } => { be.browse_endpoint_context_supported_configs.map(|config| {
if watch_endpoint (
config.browse_endpoint_context_music_config.page_type.into(),
be.browse_id,
)
})
})
.or_else(|| {
self.watch_endpoint.map(|watch| {
if watch
.playlist_id .playlist_id
.map(|plid| plid.starts_with("RDQM")) .map(|plid| plid.starts_with("RDQM"))
.unwrap_or_default() .unwrap_or_default()
{ {
// Genre radios (e.g. "pop radio") will be skipped // Genre radios (e.g. "pop radio") will be skipped
Some(MusicPage { (MusicPageType::None, watch.video_id)
id: watch_endpoint.video_id,
typ: MusicPageType::None,
})
} else { } else {
Some(MusicPage { (
id: watch_endpoint.video_id, MusicPageType::Track {
typ: MusicPageType::Track { is_video: watch
vtype: watch_endpoint
.watch_endpoint_music_supported_configs .watch_endpoint_music_supported_configs
.watch_endpoint_music_config .watch_endpoint_music_config
.music_video_type, .music_video_type
== MusicVideoType::Video,
}, },
}) watch.video_id,
}
}
NavigationEndpoint::Browse {
browse_endpoint, ..
} => browse_endpoint
.browse_endpoint_context_supported_configs
.map(|config| {
MusicPage::from_browse(
browse_endpoint.browse_id,
config.browse_endpoint_context_music_config.page_type,
) )
}),
NavigationEndpoint::Url { .. } => None,
NavigationEndpoint::WatchPlaylist {
watch_playlist_endpoint,
} => Some(MusicPage {
id: watch_playlist_endpoint.playlist_id,
typ: MusicPageType::Playlist { is_podcast: false },
}),
NavigationEndpoint::CreatePlaylist { .. } => Some(MusicPage {
id: String::new(),
typ: MusicPageType::None,
}),
} }
}
/// Get the page type of a browse endpoint
pub(crate) fn page_type(&self) -> Option<PageType> {
if let NavigationEndpoint::Browse {
browse_endpoint,
command_metadata,
} = self
{
browse_endpoint
.browse_endpoint_context_supported_configs
.as_ref()
.map(|c| c.browse_endpoint_context_music_config.page_type)
.or_else(|| {
command_metadata
.as_ref()
.map(|c| c.web_command_metadata.web_page_type)
}) })
} else { })
None
}
}
pub(crate) fn into_playlist_id(self) -> Option<String> {
match self {
NavigationEndpoint::Watch { watch_endpoint } => watch_endpoint.playlist_id,
NavigationEndpoint::Browse {
browse_endpoint,
command_metadata,
} => Some(browse_endpoint.browse_id).filter(|_| {
browse_endpoint
.browse_endpoint_context_supported_configs
.map(|c| c.browse_endpoint_context_music_config.page_type == PageType::Playlist)
.unwrap_or_default()
|| command_metadata
.map(|c| c.web_command_metadata.web_page_type == PageType::Playlist)
.unwrap_or_default()
}),
NavigationEndpoint::Url { .. } => None,
NavigationEndpoint::WatchPlaylist {
watch_playlist_endpoint,
} => Some(watch_playlist_endpoint.playlist_id),
NavigationEndpoint::CreatePlaylist { .. } => None,
}
} }
} }

View file

@ -3,8 +3,9 @@
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 crate::serializer::text::TextComponent;
use crate::serializer::{ use crate::serializer::{
text::{AccessibilityText, AttributedText, Text, TextComponent, TextComponents}, text::{AccessibilityText, AttributedText, Text, TextComponents},
MapResult, MapResult,
}; };
@ -12,10 +13,7 @@ use super::{
url_endpoint::BrowseEndpointWrap, ContinuationEndpoint, ContinuationItemRenderer, Icon, url_endpoint::BrowseEndpointWrap, ContinuationEndpoint, ContinuationItemRenderer, Icon,
MusicContinuationData, Thumbnails, MusicContinuationData, Thumbnails,
}; };
use super::{ use super::{ChannelBadge, ContentsRendererLogged, ResponseContext, YouTubeListItem};
ChannelBadge, ContentsRendererLogged, FrameworkUpdates, ImageView, ResponseContext,
YouTubeListItem,
};
/* /*
#VIDEO DETAILS #VIDEO DETAILS
@ -79,8 +77,8 @@ pub(crate) enum VideoResultsItem {
/// Like/Dislike button /// Like/Dislike button
video_actions: VideoActions, video_actions: VideoActions,
/// Absolute textual date (e.g. `Dec 29, 2019`) /// Absolute textual date (e.g. `Dec 29, 2019`)
#[serde_as(as = "Option<Text>")] #[serde_as(as = "Text")]
date_text: Option<String>, date_text: String,
}, },
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
VideoSecondaryInfoRenderer { VideoSecondaryInfoRenderer {
@ -149,46 +147,6 @@ pub(crate) enum TopLevelButton {
SegmentedLikeDislikeButtonRenderer { SegmentedLikeDislikeButtonRenderer {
like_button: ToggleButtonWrap, like_button: ToggleButtonWrap,
}, },
#[serde(rename_all = "camelCase")]
SegmentedLikeDislikeButtonViewModel {
like_button_view_model: LikeButtonViewModelWrap,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LikeButtonViewModelWrap {
pub like_button_view_model: LikeButtonViewModel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LikeButtonViewModel {
pub toggle_button_view_model: ToggleButtonViewModelWrap,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ToggleButtonViewModelWrap {
pub toggle_button_view_model: ToggleButtonViewModel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ToggleButtonViewModel {
pub default_button_view_model: ButtonViewModelWrap,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ButtonViewModelWrap {
pub button_view_model: ButtonViewModel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ButtonViewModel {
pub accessibility_text: String,
} }
/// Like/Dislike button /// Like/Dislike button
@ -478,7 +436,6 @@ pub(crate) struct VideoComments {
/// - n*commentRenderer, continuationItemRenderer: /// - n*commentRenderer, continuationItemRenderer:
/// replies + continuation /// replies + continuation
pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>, pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>,
pub framework_updates: Option<FrameworkUpdates<Payload>>,
} }
/// Video comments continuation /// Video comments continuation
@ -501,13 +458,23 @@ pub(crate) struct AppendComments {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) enum CommentListItem { pub(crate) enum CommentListItem {
/// Top-level comment /// Top-level comment
CommentThreadRenderer(CommentThreadRenderer), #[serde(rename_all = "camelCase")]
CommentThreadRenderer {
comment: Comment,
/// Continuation token to fetch replies
#[serde(default)]
replies: Replies,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
rendering_priority: CommentPriority,
},
/// Reply comment /// Reply comment
CommentRenderer(CommentRenderer), CommentRenderer(CommentRenderer),
/// Reply comment (A/B #14)
CommentViewModel(CommentViewModel),
/// Continuation token to fetch more comments /// Continuation token to fetch more comments
ContinuationItemRenderer(ContinuationItemVariants), #[serde(rename_all = "camelCase")]
ContinuationItemRenderer {
continuation_endpoint: ContinuationEndpoint,
},
/// Header of the comment section (contains number of comments) /// Header of the comment section (contains number of comments)
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
CommentsHeaderRenderer { CommentsHeaderRenderer {
@ -517,46 +484,6 @@ pub(crate) enum CommentListItem {
}, },
} }
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ContinuationItemVariants {
#[serde(rename_all = "camelCase")]
Ep {
continuation_endpoint: ContinuationEndpoint,
},
Btn {
button: ContinuationButton,
},
}
impl ContinuationItemVariants {
pub fn token(self) -> String {
match self {
ContinuationItemVariants::Ep {
continuation_endpoint,
} => continuation_endpoint,
ContinuationItemVariants::Btn { button } => button.button_renderer.command,
}
.continuation_command
.token
}
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentThreadRenderer {
/// Missing on the FrameworkUpdate data model (A/B #14)
pub comment: Option<Comment>,
pub comment_view_model: Option<CommentViewModelWrap>,
/// Continuation token to fetch replies
#[serde(default)]
pub replies: Replies,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub rendering_priority: CommentPriority,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Comment { pub(crate) struct Comment {
@ -597,7 +524,7 @@ pub(crate) struct CommentRenderer {
pub action_buttons: CommentActionButtons, pub action_buttons: CommentActionButtons,
} }
#[derive(Default, Clone, Copy, Debug, Deserialize)] #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum CommentPriority { pub(crate) enum CommentPriority {
/// Default rendering priority /// Default rendering priority
@ -607,27 +534,6 @@ pub(crate) enum CommentPriority {
RenderingPriorityPinnedComment, RenderingPriorityPinnedComment,
} }
impl From<CommentPriority> for bool {
fn from(value: CommentPriority) -> Self {
matches!(value, CommentPriority::RenderingPriorityPinnedComment)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentViewModelWrap {
pub comment_view_model: CommentViewModel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentViewModel {
pub comment_id: String,
pub comment_key: String,
pub comment_surface_key: String,
pub toolbar_state_key: String,
}
/// Does not contain replies directly but a continuation token /// Does not contain replies directly but a continuation token
/// for fetching them. /// for fetching them.
#[derive(Default, Debug, Deserialize)] #[derive(Default, Debug, Deserialize)]
@ -691,107 +597,3 @@ pub(crate) struct AuthorCommentBadgeRenderer {
/// Artist: `OFFICIAL_ARTIST_BADGE` /// Artist: `OFFICIAL_ARTIST_BADGE`
pub icon: Icon, pub icon: Icon,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum Payload {
CommentEntityPayload(CommentEntityPayload),
CommentSurfaceEntityPayload(CommentSurfaceEntityPayload),
#[serde(rename_all = "camelCase")]
EngagementToolbarStateEntityPayload {
heart_state: HeartState,
},
#[serde(other, deserialize_with = "deserialize_ignore_any")]
None,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentEntityPayload {
pub properties: CommentProperties,
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub author: Option<CommentAuthor>,
pub toolbar: CommentToolbar,
#[serde(default)]
pub avatar: ImageView,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentSurfaceEntityPayload {
pub voice_reply_container_view_model: Option<VoiceReplyContainer>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentProperties {
#[serde_as(as = "AttributedText")]
pub content: TextComponents,
pub published_time: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentAuthor {
pub channel_id: String,
pub display_name: String,
#[serde(default)]
pub is_verified: bool,
#[serde(default)]
pub is_artist: bool,
#[serde(default)]
pub is_creator: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentToolbar {
pub like_count_notliked: String,
pub reply_count: String,
}
#[derive(Debug, Copy, Clone, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum HeartState {
ToolbarHeartStateUnhearted,
ToolbarHeartStateHearted,
}
impl From<HeartState> for bool {
fn from(value: HeartState) -> Self {
match value {
HeartState::ToolbarHeartStateUnhearted => false,
HeartState::ToolbarHeartStateHearted => true,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationButton {
pub button_renderer: ContinuationButtonRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationButtonRenderer {
pub command: ContinuationEndpoint,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct VoiceReplyContainer {
pub voice_reply_container_view_model: VoiceReplyContainer2,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct VoiceReplyContainer2 {
#[serde_as(as = "AttributedText")]
pub transcript_text: TextComponents,
}

View file

@ -1,25 +1,25 @@
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use serde_with::{ use serde_with::{
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError, json::JsonString, rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError,
}; };
use time::OffsetDateTime; use time::OffsetDateTime;
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails}; use super::{url_endpoint::NavigationEndpoint, ChannelBadge, ContinuationEndpoint, Thumbnails};
use crate::{ use crate::{
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem}, model::{
Channel, ChannelId, ChannelInfo, ChannelItem, ChannelTag, PlaylistItem, Verification,
VideoItem, YouTubeItem,
},
param::Language, param::Language,
serializer::{ serializer::{
text::{AttributedText, Text, TextComponent}, text::{AccessibilityText, Text, TextComponent},
MapResult, MapResult,
}, },
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")]
@ -27,7 +27,6 @@ pub(crate) enum YouTubeListItem {
#[serde(alias = "gridVideoRenderer", alias = "compactVideoRenderer")] #[serde(alias = "gridVideoRenderer", alias = "compactVideoRenderer")]
VideoRenderer(VideoRenderer), VideoRenderer(VideoRenderer),
ReelItemRenderer(ReelItemRenderer), ReelItemRenderer(ReelItemRenderer),
ShortsLockupViewModel(ShortsLockupViewModel),
PlaylistVideoRenderer(PlaylistVideoRenderer), PlaylistVideoRenderer(PlaylistVideoRenderer),
#[serde(alias = "gridPlaylistRenderer")] #[serde(alias = "gridPlaylistRenderer")]
@ -35,8 +34,6 @@ pub(crate) enum YouTubeListItem {
ChannelRenderer(ChannelRenderer), ChannelRenderer(ChannelRenderer),
LockupViewModel(LockupViewModel),
/// Continauation items are located at the end of a list /// Continauation items are located at the end of a list
/// and contain the continuation token for progressive loading /// and contain the continuation token for progressive loading
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -51,6 +48,9 @@ pub(crate) enum YouTubeListItem {
corrected_query: String, corrected_query: String,
}, },
/// Channel metadata (about tab)
ChannelAboutFullMetadataRenderer(ChannelFullMetadata),
/// Contains video on startpage /// Contains video on startpage
/// ///
/// Seems to be currently A/B tested on the channel page, /// Seems to be currently A/B tested on the channel page,
@ -68,20 +68,10 @@ 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>,
#[serde(alias = "items")] #[serde(alias = "items")]
contents: MapResult<Vec<YouTubeListItem>>, contents: MapResult<Vec<YouTubeListItem>>,
}, },
/// Age-restricted channel
#[serde(rename_all = "camelCase")]
ChannelAgeGateRenderer {
channel_title: String,
#[serde_as(as = "Text")]
main_text: String,
},
/// No video list item (e.g. ad) or unimplemented item /// No video list item (e.g. ad) or unimplemented item
/// ///
/// Unimplemented: /// Unimplemented:
@ -144,71 +134,18 @@ pub(crate) struct ReelItemRenderer {
/// Contains `No views` if the view count is zero /// Contains `No views` if the view count is zero
#[serde_as(as = "Option<Text>")] #[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>, pub view_count_text: Option<String>,
/// video duration
///
/// Example: `the horror maze - 44 seconds - play video`
///
/// Dashes may be `\u2013` (emdash)
#[serde_as(as = "Option<AccessibilityText>")]
pub accessibility: Option<String>,
#[serde(default)] #[serde(default)]
#[serde_as(as = "DefaultOnError")] #[serde_as(as = "DefaultOnError")]
pub navigation_endpoint: Option<ReelNavigationEndpoint>, pub navigation_endpoint: Option<ReelNavigationEndpoint>,
} }
// New short video item
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ShortsLockupViewModel {
/// `shorts-shelf-item-[video_id]`
pub entity_id: String,
pub thumbnail: Thumbnails,
pub overlay_metadata: ShortsOverlayMetadata,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ShortsOverlayMetadata {
/// Title
#[serde_as(as = "AttributedText")]
pub primary_text: String,
/// View count
#[serde_as(as = "Option<AttributedText>")]
pub secondary_text: Option<String>,
}
/// Generalized list item, currently only used for channel playlists and YTM items
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockupViewModel {
pub content_id: String,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub content_type: LockupContentType,
pub content_image: ContentImage,
pub metadata: LockupViewModelMetadata,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[allow(clippy::enum_variant_names)]
pub(crate) enum LockupContentType {
LockupContentTypePlaylist,
LockupContentTypeVideo,
#[default]
Unknown,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockupViewModelMetadata {
pub lockup_metadata_view_model: LockupViewModelMetadataInner,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockupViewModelMetadataInner {
#[serde_as(as = "AttributedText")]
pub title: String,
pub metadata: PhMetadataView,
}
/// Video displayed in a playlist /// Video displayed in a playlist
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -220,7 +157,7 @@ pub(crate) struct PlaylistVideoRenderer {
pub title: String, pub title: String,
#[serde(rename = "shortBylineText")] #[serde(rename = "shortBylineText")]
pub channel: TextComponent, pub channel: TextComponent,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<JsonString>")]
pub length_seconds: Option<u32>, pub length_seconds: Option<u32>,
/// Regular video: `["29K views", " • ", "13 years ago"]` /// Regular video: `["29K views", " • ", "13 years ago"]`
/// Livestream: `["66K", " watching"]` /// Livestream: `["66K", " watching"]`
@ -250,7 +187,7 @@ pub(crate) struct PlaylistRenderer {
/// The first item of this list contains the playlist thumbnail, /// The first item of this list contains the playlist thumbnail,
/// subsequent items contain very small thumbnails of the next playlist videos /// subsequent items contain very small thumbnails of the next playlist videos
pub thumbnails: Option<Vec<Thumbnails>>, pub thumbnails: Option<Vec<Thumbnails>>,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<JsonString>")]
pub video_count: Option<u64>, pub video_count: Option<u64>,
#[serde_as(as = "Option<Text>")] #[serde_as(as = "Option<Text>")]
pub video_count_short_text: Option<String>, pub video_count_short_text: Option<String>,
@ -301,19 +238,12 @@ pub(crate) struct YouTubeListRenderer {
pub contents: MapResult<Vec<YouTubeListItem>>, pub contents: MapResult<Vec<YouTubeListItem>>,
} }
#[cfg(feature = "userdata")]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ItemSectionHeader {
pub item_section_header_renderer: SimpleHeaderRenderer,
}
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct UpcomingEventData { pub(crate) struct UpcomingEventData {
/// Unixtime in seconds /// Unixtime in seconds
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "JsonString")]
pub start_time: i64, pub start_time: i64,
} }
@ -428,6 +358,28 @@ pub(crate) struct ReelPlayerHeaderRenderer {
pub timestamp_text: String, pub timestamp_text: String,
} }
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelFullMetadata {
#[serde_as(as = "Text")]
pub joined_date_text: String,
#[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub primary_links: Vec<PrimaryLink>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PrimaryLink {
#[serde_as(as = "Text")]
pub title: String,
pub navigation_endpoint: NavigationEndpoint,
}
trait IsLive { trait IsLive {
fn is_live(&self) -> bool; fn is_live(&self) -> bool;
} }
@ -460,6 +412,10 @@ impl IsShort for Vec<TimeOverlay> {
} }
} }
static ACCESSIBILITY_SEP_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new("(?:[ \u{00a0}][-\u{2013}\u{2014}] )|\u{2013}|(?:\u{055d} )|(?:\", )").unwrap()
});
/// Result of mapping a list of different YouTube enities /// Result of mapping a list of different YouTube enities
/// (videos, channels, playlists) /// (videos, channels, playlists)
#[derive(Debug)] #[derive(Debug)]
@ -471,6 +427,7 @@ pub(crate) struct YouTubeListMapper<T> {
pub warnings: Vec<String>, pub warnings: Vec<String>,
pub ctoken: Option<String>, pub ctoken: Option<String>,
pub corrected_query: Option<String>, pub corrected_query: Option<String>,
pub channel_info: Option<ChannelInfo>,
} }
impl<T> YouTubeListMapper<T> { impl<T> YouTubeListMapper<T> {
@ -482,6 +439,7 @@ impl<T> YouTubeListMapper<T> {
warnings: Vec::new(), warnings: Vec::new(),
ctoken: None, ctoken: None,
corrected_query: None, corrected_query: None,
channel_info: None,
} }
} }
@ -499,6 +457,7 @@ impl<T> YouTubeListMapper<T> {
warnings, warnings,
ctoken: None, ctoken: None,
corrected_query: None, corrected_query: None,
channel_info: None,
} }
} }
@ -519,22 +478,23 @@ impl<T> YouTubeListMapper<T> {
VideoItem { VideoItem {
id: video.video_id, id: video.video_id,
name: video.title, name: video.title,
duration: length_text.and_then(|txt| util::parse_video_length(&txt)), length: length_text.and_then(|txt| util::parse_video_length(&txt)),
thumbnail: video.thumbnail.into(), thumbnail: video.thumbnail.into(),
channel: video channel: video
.channel .channel
.and_then(|c| ChannelTag::try_from(c).ok()) .and_then(|c| {
.map(|mut c| { ChannelId::try_from(c).ok().map(|c| ChannelTag {
c.avatar = video id: c.id,
name: c.name,
avatar: video
.channel_thumbnail_supported_renderers .channel_thumbnail_supported_renderers
.map(|tn| tn.channel_thumbnail_with_link_renderer.thumbnail) .map(|tn| tn.channel_thumbnail_with_link_renderer.thumbnail)
.or(video.channel_thumbnail) .or(video.channel_thumbnail)
.unwrap_or_default() .unwrap_or_default()
.into(); .into(),
if !c.verification.verified() { verification: video.owner_badges.into(),
c.verification = video.owner_badges.into(); subscriber_count: None,
} })
c
}) })
.or_else(|| self.channel.clone()), .or_else(|| self.channel.clone()),
publish_date: video publish_date: video
@ -570,10 +530,29 @@ impl<T> YouTubeListMapper<T> {
.timestamp_text .timestamp_text
}); });
let length = video.accessibility.and_then(|acc| {
let parts = ACCESSIBILITY_SEP_REGEX.split(&acc).collect::<Vec<_>>();
if parts.len() > 2 {
let i = match self.lang {
Language::Ru => 1,
_ => 2,
};
timeago::parse_video_duration_or_warn(
self.lang,
parts[parts.len() - i],
&mut self.warnings,
)
} else {
self.warnings
.push(format!("could not split video duration `{acc}`"));
None
}
});
VideoItem { VideoItem {
id: video.video_id, id: video.video_id,
name: video.headline, name: video.headline,
duration: None, length,
thumbnail: video.thumbnail.into(), thumbnail: video.thumbnail.into(),
channel: self.channel.clone(), channel: self.channel.clone(),
publish_date: pub_date_txt.as_ref().and_then(|txt| { publish_date: pub_date_txt.as_ref().and_then(|txt| {
@ -590,33 +569,17 @@ impl<T> YouTubeListMapper<T> {
} }
} }
fn map_short_video2(&mut self, video: ShortsLockupViewModel) -> Option<VideoItem> {
if let Some(video_id) = video.entity_id.strip_prefix("shorts-shelf-item-") {
Some(VideoItem {
id: video_id.to_owned(),
name: video.overlay_metadata.primary_text,
duration: None,
thumbnail: video.thumbnail.into(),
channel: self.channel.clone(),
publish_date: None,
publish_date_txt: None,
view_count: video.overlay_metadata.secondary_text.and_then(|txt| {
util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings)
}),
is_live: false,
is_short: true,
is_upcoming: false,
short_description: None,
})
} else {
self.warnings
.push(format!("invalid shorts entityId: {}", video.entity_id));
None
}
}
fn map_playlist_video(&mut self, video: PlaylistVideoRenderer) -> VideoItem { fn map_playlist_video(&mut self, video: PlaylistVideoRenderer) -> VideoItem {
let channel = ChannelTag::try_from(video.channel).ok(); let channel = ChannelId::try_from(video.channel)
.ok()
.map(|ch| ChannelTag {
id: ch.id,
name: ch.name,
avatar: Vec::new(),
verification: Verification::None,
subscriber_count: None,
});
let mut video_info = video.video_info.into_iter(); let mut video_info = video.video_info.into_iter();
let video_info1 = video_info let video_info1 = video_info
.next() .next()
@ -653,7 +616,7 @@ impl<T> YouTubeListMapper<T> {
VideoItem { VideoItem {
id: video.video_id, id: video.video_id,
name: video.title, name: video.title,
duration: video.length_seconds, length: video.length_seconds,
thumbnail: video.thumbnail.into(), thumbnail: video.thumbnail.into(),
channel, channel,
publish_date, publish_date,
@ -679,12 +642,14 @@ impl<T> YouTubeListMapper<T> {
.into(), .into(),
channel: playlist channel: playlist
.channel .channel
.and_then(|c| ChannelTag::try_from(c).ok()) .and_then(|c| {
.map(|mut c| { ChannelId::try_from(c).ok().map(|c| ChannelTag {
if !c.verification.verified() { id: c.id,
c.verification = playlist.owner_badges.into(); name: c.name,
} avatar: Vec::new(),
c verification: playlist.owner_badges.into(),
subscriber_count: None,
})
}) })
.or_else(|| self.channel.clone()), .or_else(|| self.channel.clone()),
video_count: playlist.video_count.or_else(|| { video_count: playlist.video_count.or_else(|| {
@ -697,112 +662,31 @@ impl<T> YouTubeListMapper<T> {
fn map_channel(&mut self, channel: ChannelRenderer) -> ChannelItem { fn map_channel(&mut self, channel: ChannelRenderer) -> ChannelItem {
// channel handle instead of subscriber count (A/B test 3) // channel handle instead of subscriber count (A/B test 3)
let (handle, sc_txt) = if channel let (sc_txt, vc_text) = if channel
.subscriber_count_text .subscriber_count_text
.as_ref() .as_ref()
.map(|txt| txt.starts_with('@')) .map(|txt| txt.starts_with('@'))
.unwrap_or_default() .unwrap_or_default()
{ {
(channel.subscriber_count_text, channel.video_count_text) (channel.video_count_text, None)
} else { } else {
(None, channel.subscriber_count_text) (channel.subscriber_count_text, channel.video_count_text)
}; };
ChannelItem { ChannelItem {
id: channel.channel_id, id: channel.channel_id,
name: channel.title, name: channel.title,
handle,
avatar: channel.thumbnail.into(), avatar: channel.thumbnail.into(),
verification: channel.owner_badges.into(), verification: channel.owner_badges.into(),
subscriber_count: sc_txt.and_then(|txt| { subscriber_count: sc_txt.and_then(|txt| {
util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings) util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings)
}), }),
video_count: vc_text.and_then(|txt| {
util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings)
}),
short_description: channel.description_snippet, short_description: channel.description_snippet,
} }
} }
fn map_lockup(&mut self, lockup: LockupViewModel) -> Option<YouTubeItem> {
let md = lockup.metadata.lockup_metadata_view_model;
let tn = lockup.content_image.into_image();
match lockup.content_type {
LockupContentType::LockupContentTypePlaylist => {
Some(YouTubeItem::Playlist(PlaylistItem {
id: lockup.content_id,
name: md.title,
thumbnail: tn.image.into(),
channel: self.channel.clone(),
video_count: tn
.overlays
.first()
.and_then(|ol| {
ol.thumbnail_overlay_badge_view_model
.thumbnail_badges
.first()
})
.and_then(|badge| {
util::parse_numeric(&badge.thumbnail_badge_view_model.text).ok()
}),
}))
}
LockupContentType::LockupContentTypeVideo => {
let mut mdr = md
.metadata
.content_metadata_view_model
.metadata_rows
.into_iter();
let channel = mdr
.next()
.and_then(|r| r.metadata_parts.into_iter().next())
.and_then(|p| ChannelTag::try_from(p.into_text_component()).ok());
let (view_count, publish_date_txt) = mdr
.next()
.map(|metadata_row| {
let mut parts = metadata_row.metadata_parts.into_iter();
let p1 = parts.next();
let p2 = parts.next();
(
p1.and_then(|p| {
util::parse_large_numstr_or_warn(
p.as_str(),
self.lang,
&mut self.warnings,
)
}),
p2.map(|p2| p2.into_text_component().into_string()),
)
})
.unwrap_or_default();
Some(YouTubeItem::Video(VideoItem {
id: lockup.content_id,
name: md.title,
duration: tn
.overlays
.first()
.and_then(|ol| {
ol.thumbnail_overlay_badge_view_model
.thumbnail_badges
.first()
})
.and_then(|badge| {
util::parse_video_length(&badge.thumbnail_badge_view_model.text)
}),
thumbnail: tn.image.into(),
channel,
publish_date: publish_date_txt.as_deref().and_then(|t| {
timeago::parse_timeago_dt_or_warn(self.lang, t, &mut self.warnings)
}),
publish_date_txt,
view_count,
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
}))
}
LockupContentType::Unknown => None,
}
}
} }
impl YouTubeListMapper<YouTubeItem> { impl YouTubeListMapper<YouTubeItem> {
@ -812,11 +696,6 @@ impl YouTubeListMapper<YouTubeItem> {
let mapped = YouTubeItem::Video(self.map_video(video)); let mapped = YouTubeItem::Video(self.map_video(video));
self.items.push(mapped); self.items.push(mapped);
} }
YouTubeListItem::ShortsLockupViewModel(video) => {
if let Some(mapped) = self.map_short_video2(video) {
self.items.push(YouTubeItem::Video(mapped));
}
}
YouTubeListItem::ReelItemRenderer(video) => { YouTubeListItem::ReelItemRenderer(video) => {
let mapped = self.map_short_video(video); let mapped = self.map_short_video(video);
self.items.push(YouTubeItem::Video(mapped)); self.items.push(YouTubeItem::Video(mapped));
@ -833,25 +712,42 @@ impl YouTubeListMapper<YouTubeItem> {
let mapped = YouTubeItem::Channel(self.map_channel(channel)); let mapped = YouTubeItem::Channel(self.map_channel(channel));
self.items.push(mapped); self.items.push(mapped);
} }
YouTubeListItem::LockupViewModel(lockup) => {
if let Some(mapped) = self.map_lockup(lockup) {
self.items.push(mapped);
}
}
YouTubeListItem::ContinuationItemRenderer { YouTubeListItem::ContinuationItemRenderer {
continuation_endpoint, continuation_endpoint,
} => self.ctoken = Some(continuation_endpoint.continuation_command.token), } => self.ctoken = Some(continuation_endpoint.continuation_command.token),
YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { YouTubeListItem::ShowingResultsForRenderer { corrected_query } => {
self.corrected_query = Some(corrected_query); self.corrected_query = Some(corrected_query);
} }
YouTubeListItem::ChannelAboutFullMetadataRenderer(meta) => {
self.channel_info = Some(ChannelInfo {
create_date: timeago::parse_textual_date_or_warn(
self.lang,
&meta.joined_date_text,
&mut self.warnings,
)
.map(OffsetDateTime::date),
view_count: meta
.view_count_text
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut self.warnings)),
links: meta
.primary_links
.into_iter()
.filter_map(|l| {
l.navigation_endpoint
.url_endpoint
.map(|url| (l.title, util::sanitize_yt_url(&url.url)))
})
.collect(),
});
}
YouTubeListItem::RichItemRenderer { content } => { YouTubeListItem::RichItemRenderer { content } => {
self.map_item(*content); self.map_item(*content);
} }
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => { YouTubeListItem::ItemSectionRenderer { mut contents } => {
self.warnings.append(&mut contents.warnings); self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it)); contents.c.into_iter().for_each(|it| self.map_item(it));
} }
YouTubeListItem::None | YouTubeListItem::ChannelAgeGateRenderer { .. } => {} YouTubeListItem::None => {}
} }
} }
@ -872,20 +768,10 @@ impl YouTubeListMapper<VideoItem> {
let mapped = self.map_short_video(video); let mapped = self.map_short_video(video);
self.items.push(mapped); self.items.push(mapped);
} }
YouTubeListItem::ShortsLockupViewModel(video) => {
if let Some(mapped) = self.map_short_video2(video) {
self.items.push(mapped);
}
}
YouTubeListItem::PlaylistVideoRenderer(video) => { YouTubeListItem::PlaylistVideoRenderer(video) => {
let mapped = self.map_playlist_video(video); let mapped = self.map_playlist_video(video);
self.items.push(mapped); self.items.push(mapped);
} }
YouTubeListItem::LockupViewModel(lockup) => {
if let Some(YouTubeItem::Video(mapped)) = self.map_lockup(lockup) {
self.items.push(mapped);
}
}
YouTubeListItem::ContinuationItemRenderer { YouTubeListItem::ContinuationItemRenderer {
continuation_endpoint, continuation_endpoint,
} => self.ctoken = Some(continuation_endpoint.continuation_command.token), } => self.ctoken = Some(continuation_endpoint.continuation_command.token),
@ -895,7 +781,7 @@ impl YouTubeListMapper<VideoItem> {
YouTubeListItem::RichItemRenderer { content } => { YouTubeListItem::RichItemRenderer { content } => {
self.map_item(*content); self.map_item(*content);
} }
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => { YouTubeListItem::ItemSectionRenderer { mut contents } => {
self.warnings.append(&mut contents.warnings); self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it)); contents.c.into_iter().for_each(|it| self.map_item(it));
} }
@ -907,23 +793,6 @@ impl YouTubeListMapper<VideoItem> {
self.warnings.append(&mut res.warnings); self.warnings.append(&mut res.warnings);
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(
self,
date_txt: Option<String>,
utc_offset: UtcOffset,
res: &mut MapResult<Vec<HistoryItem<VideoItem>>>,
) {
res.warnings.extend(self.warnings);
res.c.extend(self.items.into_iter().map(|item| HistoryItem {
item,
playback_date: date_txt.as_deref().and_then(|s| {
timeago::parse_textual_date_to_d(self.lang, utc_offset, s, &mut res.warnings)
}),
playback_date_txt: date_txt.clone(),
}));
}
} }
impl YouTubeListMapper<PlaylistItem> { impl YouTubeListMapper<PlaylistItem> {
@ -933,11 +802,6 @@ impl YouTubeListMapper<PlaylistItem> {
let mapped = self.map_playlist(playlist); let mapped = self.map_playlist(playlist);
self.items.push(mapped); self.items.push(mapped);
} }
YouTubeListItem::LockupViewModel(lockup) => {
if let Some(YouTubeItem::Playlist(mapped)) = self.map_lockup(lockup) {
self.items.push(mapped);
}
}
YouTubeListItem::ContinuationItemRenderer { YouTubeListItem::ContinuationItemRenderer {
continuation_endpoint, continuation_endpoint,
} => self.ctoken = Some(continuation_endpoint.continuation_command.token), } => self.ctoken = Some(continuation_endpoint.continuation_command.token),
@ -947,7 +811,7 @@ impl YouTubeListMapper<PlaylistItem> {
YouTubeListItem::RichItemRenderer { content } => { YouTubeListItem::RichItemRenderer { content } => {
self.map_item(*content); self.map_item(*content);
} }
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => { YouTubeListItem::ItemSectionRenderer { mut contents } => {
self.warnings.append(&mut contents.warnings); self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it)); contents.c.into_iter().for_each(|it| self.map_item(it));
} }
@ -960,3 +824,50 @@ impl YouTubeListMapper<PlaylistItem> {
res.c.into_iter().for_each(|item| self.map_item(item)); res.c.into_iter().for_each(|item| self.map_item(item));
} }
} }
#[cfg(test)]
mod tests {
use super::ACCESSIBILITY_SEP_REGEX;
use rstest::rstest;
#[rstest]
#[case::af(
"BTS - Permission to Dance Cover #shorts #pinkfong 50 sekondes speel video",
"50 sekondes"
)]
#[case::de(
"Point of view: Me VS My mom #shorts  8 Sekunden  Video wiedergeben",
"8 Sekunden"
)]
#[case::be(
"Point of view: Me VS My mom #shorts8 секунд прайграць відэа",
"8 секунд"
)]
#[case::fil("do u wanna get swole? - 53 segundo - i-play ang video", "53 segundo")]
#[case::ar(
"«the holy trinity of korean street food»՝ 1 րոպե՝ նվագարկել տեսանյութը",
"1 րոպե"
)]
#[case::lv(
"what i ate in google japan — 1 minūte — atskaņot videoklipu",
"1 minūte"
)]
#[case::sq("When you impulse buy... - 1 minutë - luaj videon", "1 minutë")]
#[case::uk(
"\"Point of view: Me VS My mom #shorts\", 8 секунд відтворити відео",
"8 секунд"
)]
// INFO: sw is unparseable "coming soonsekunde 58 - cheza video"
fn split_duration_txt(#[case] s: &str, #[case] expect: &str) {
let parts = ACCESSIBILITY_SEP_REGEX.split(s).collect::<Vec<_>>();
assert_eq!(parts[parts.len() - 2], expect);
}
#[test]
fn split_duration_txt_ru() {
let s = "Воспроизвести видео – \"the holy trinity of korean street food\". Его продолжительность – 1 минута.";
let parts = ACCESSIBILITY_SEP_REGEX.split(s).collect::<Vec<_>>();
assert_eq!(parts[parts.len() - 1], "1 минута.");
}
}

View file

@ -1,37 +1,31 @@
use std::fmt::Debug;
use serde::Serialize; use serde::Serialize;
use crate::{ use crate::{
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{paginator::Paginator, SearchResult, YouTubeItem},
paginator::{ContinuationEndpoint, Paginator},
traits::FromYtItem,
SearchResult, YouTubeItem,
},
param::search_filter::SearchFilter, param::search_filter::SearchFilter,
}; };
use super::{response, ClientType, MapRespCtx, MapResponse, MapResult, RustyPipeQuery}; use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QSearch<'a> { struct QSearch<'a> {
context: YTContext<'a>,
query: &'a str, query: &'a str,
params: &'a str, #[serde(skip_serializing_if = "Option::is_none")]
params: Option<String>,
} }
impl RustyPipeQuery { impl RustyPipeQuery {
/// Search YouTube /// Search YouTube
#[tracing::instrument(skip(self), level = "error")] pub async fn search<S: AsRef<str>>(&self, query: S) -> Result<SearchResult, Error> {
pub async fn search<T: FromYtItem, S: AsRef<str> + Debug>(
&self,
query: S,
) -> Result<SearchResult<T>, Error> {
let query = query.as_ref(); let query = query.as_ref();
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QSearch { let request_body = QSearch {
context,
query, query,
params: "8AEB", params: None,
}; };
self.execute_request::<response::Search, _, _>( self.execute_request::<response::Search, _, _>(
@ -45,16 +39,17 @@ impl RustyPipeQuery {
} }
/// Search YouTube using the given [`SearchFilter`] /// Search YouTube using the given [`SearchFilter`]
#[tracing::instrument(skip(self), level = "error")] pub async fn search_filter<S: AsRef<str>>(
pub async fn search_filter<T: FromYtItem, S: AsRef<str> + Debug>(
&self, &self,
query: S, query: S,
filter: &SearchFilter, filter: &SearchFilter,
) -> Result<SearchResult<T>, Error> { ) -> Result<SearchResult, Error> {
let query = query.as_ref(); let query = query.as_ref();
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QSearch { let request_body = QSearch {
context,
query, query,
params: &filter.encode(), params: Some(filter.encode()),
}; };
self.execute_request::<response::Search, _, _>( self.execute_request::<response::Search, _, _>(
@ -68,11 +63,7 @@ impl RustyPipeQuery {
} }
/// Get YouTube search suggestions /// Get YouTube search suggestions
#[tracing::instrument(skip(self), level = "error")] pub async fn search_suggestion<S: AsRef<str>>(&self, query: S) -> Result<Vec<String>, Error> {
pub async fn search_suggestion<S: AsRef<str> + Debug>(
&self,
query: S,
) -> Result<Vec<String>, Error> {
let url = url::Url::parse_with_params( let url = url::Url::parse_with_params(
"https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&xhr=t", "https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&xhr=t",
&[ &[
@ -95,11 +86,13 @@ impl RustyPipeQuery {
} }
} }
impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search { impl MapResponse<SearchResult> for response::Search {
fn map_response( fn map_response(
self, self,
ctx: &MapRespCtx<'_>, _id: &str,
) -> Result<MapResult<SearchResult<T>>, ExtractionError> { lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<SearchResult>, ExtractionError> {
let items = self let items = self
.contents .contents
.two_column_search_results_renderer .two_column_search_results_renderer
@ -107,28 +100,20 @@ impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search {
.section_list_renderer .section_list_renderer
.contents; .contents;
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang); let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
mapper.map_response(items); mapper.map_response(items);
Ok(MapResult { Ok(MapResult {
c: SearchResult { c: SearchResult {
items: Paginator::new_ext( items: Paginator::new_ext(
self.estimated_results, self.estimated_results,
mapper mapper.items,
.items
.into_iter()
.filter_map(T::from_yt_item)
.collect(),
mapper.ctoken, mapper.ctoken,
ctx.visitor_data.map(str::to_owned), None,
ContinuationEndpoint::Search, crate::model::paginator::ContinuationEndpoint::Search,
false,
), ),
corrected_query: mapper.corrected_query, corrected_query: mapper.corrected_query,
visitor_data: self visitor_data: self.response_context.visitor_data,
.response_context
.visitor_data
.or_else(|| ctx.visitor_data.map(str::to_owned)),
}, },
warnings: mapper.warnings, warnings: mapper.warnings,
}) })
@ -143,8 +128,9 @@ mod tests {
use rstest::rstest; use rstest::rstest;
use crate::{ use crate::{
client::{response, MapRespCtx, MapResponse}, client::{response, MapResponse},
model::{SearchResult, YouTubeItem}, model::SearchResult,
param::Language,
serializer::MapResult, serializer::MapResult,
util::tests::TESTFILES, util::tests::TESTFILES,
}; };
@ -159,8 +145,7 @@ mod tests {
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let search: response::Search = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let search: response::Search = serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<SearchResult<YouTubeItem>> = let map_res: MapResult<SearchResult> = search.map_response("", Language::En, None).unwrap();
search.map_response(&MapRespCtx::test("")).unwrap();
assert!( assert!(
map_res.warnings.is_empty(), map_res.warnings.is_empty(),

View file

@ -2,15 +2,152 @@
source: src/client/channel.rs source: src/client/channel.rs
expression: map_res.c expression: map_res.c
--- ---
ChannelInfo( Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
url: "http://www.youtube.com/@EEVblog", name: "EEVblog",
subscriber_count: Some(881000),
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
width: 48,
height: 48,
),
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s176-c-k-c0x00ffffff-no-rj",
width: 176,
height: 176,
),
],
verification: Verified,
description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA",
subscriber_count: Some(920000), tags: [
video_count: Some(1920), "electronics",
"engineering",
"maker",
"hacker",
"design",
"circuit",
"hardware",
"pic",
"atmel",
"oscilloscope",
"multimeter",
"diy",
"hobby",
"review",
"teardown",
"microcontroller",
"arduino",
"video",
"blog",
"tutorial",
"how-to",
"interview",
"rant",
"industry",
"news",
"mailbag",
"dumpster diving",
"debunking",
],
vanity_url: Some("https://www.youtube.com/c/EevblogDave"),
banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1060,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1138,
height: 188,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1707,
height: 283,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2276,
height: 377,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2560,
height: 424,
),
],
mobile_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: false,
has_live: false,
visitor_data: Some("CgszMUUzZDlGLWxiRSipqr2ZBg%3D%3D"),
content: ChannelInfo(
create_date: Some("2009-04-04"), create_date: Some("2009-04-04"),
view_count: Some(199087682), view_count: Some(186854342),
country: Some(AU),
links: [ links: [
("EEVblog Web Site", "http://www.eevblog.com/"), ("EEVblog Web Site", "http://www.eevblog.com/"),
("Twitter", "http://www.twitter.com/eevblog"), ("Twitter", "http://www.twitter.com/eevblog"),
@ -26,4 +163,5 @@ ChannelInfo(
("EEVblog AMAZON Store", "http://www.amazon.com/gp/redirect.html?ie=UTF8&location=http%3A%2F%2Fwww.amazon.com%2F&tag=ee04-20&linkCode=ur2&camp=1789&creative=390957"), ("EEVblog AMAZON Store", "http://www.amazon.com/gp/redirect.html?ie=UTF8&location=http%3A%2F%2Fwww.amazon.com%2F&tag=ee04-20&linkCode=ur2&camp=1789&creative=390957"),
("2nd EEVblog Channel", "http://www.youtube.com/EEVblog2"), ("2nd EEVblog Channel", "http://www.youtube.com/EEVblog2"),
], ],
),
) )

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
handle: None,
subscriber_count: Some(884000), subscriber_count: Some(884000),
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
@ -25,7 +23,7 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: verified, verification: Verified,
description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA",
tags: [ tags: [
"electronics", "electronics",
@ -57,6 +55,7 @@ Channel(
"dumpster diving", "dumpster diving",
"debunking", "debunking",
], ],
vanity_url: Some("https://www.youtube.com/c/EevblogDave"),
banner: [ banner: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
@ -89,6 +88,60 @@ Channel(
height: 424, height: 424,
), ),
], ],
mobile_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: false, has_shorts: false,
has_live: true, has_live: true,
visitor_data: None, visitor_data: None,
@ -98,7 +151,7 @@ Channel(
VideoItem( VideoItem(
id: "hhs95CI6Dsg", id: "hhs95CI6Dsg",
name: "MARS 2020 Landing LIVE", name: "MARS 2020 Landing LIVE",
duration: Some(6321), length: Some(6321),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/hhs95CI6Dsg/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHUBoAC4AOKAgwIABABGGUgZShlMA8=&rs=AOn4CLAlPp2e1tF8gyf1cJisZGTMleissg", url: "https://i.ytimg.com/vi/hhs95CI6Dsg/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHUBoAC4AOKAgwIABABGGUgZShlMA8=&rs=AOn4CLAlPp2e1tF8gyf1cJisZGTMleissg",
@ -125,7 +178,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -139,7 +192,7 @@ Channel(
VideoItem( VideoItem(
id: "cpQk2n-wmQ4", id: "cpQk2n-wmQ4",
name: "LIVE Soldering", name: "LIVE Soldering",
duration: Some(7046), length: Some(7046),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/cpQk2n-wmQ4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCoS3qwdY2rDbhkWJOWHisORlMKnA", url: "https://i.ytimg.com/vi/cpQk2n-wmQ4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCoS3qwdY2rDbhkWJOWHisORlMKnA",
@ -166,7 +219,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -180,7 +233,7 @@ Channel(
VideoItem( VideoItem(
id: "kIDV_XN9oA8", id: "kIDV_XN9oA8",
name: "LIVE Soldering", name: "LIVE Soldering",
duration: Some(4353), length: Some(4353),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/kIDV_XN9oA8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBG3KVoFpBFIYCG2mrox_kEq6Arug", url: "https://i.ytimg.com/vi/kIDV_XN9oA8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBG3KVoFpBFIYCG2mrox_kEq6Arug",
@ -207,7 +260,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -221,7 +274,7 @@ Channel(
VideoItem( VideoItem(
id: "DWS4Qp3Yn0A", id: "DWS4Qp3Yn0A",
name: "Apollo 11 Launch LIVE - 50 Years Later", name: "Apollo 11 Launch LIVE - 50 Years Later",
duration: Some(4560), length: Some(4560),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/DWS4Qp3Yn0A/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAFkIQ4er8qDNMlD9H8lPzfSnE99g", url: "https://i.ytimg.com/vi/DWS4Qp3Yn0A/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAFkIQ4er8qDNMlD9H8lPzfSnE99g",
@ -248,7 +301,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -262,7 +315,7 @@ Channel(
VideoItem( VideoItem(
id: "LwjTe3SiVXg", id: "LwjTe3SiVXg",
name: "EEVblog LIVE Q&A", name: "EEVblog LIVE Q&A",
duration: Some(3943), length: Some(3943),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/LwjTe3SiVXg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzTlnjBJLT3KJVN4teMlX_svuaNA", url: "https://i.ytimg.com/vi/LwjTe3SiVXg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzTlnjBJLT3KJVN4teMlX_svuaNA",
@ -289,7 +342,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -303,7 +356,7 @@ Channel(
VideoItem( VideoItem(
id: "skPiz3GrVNs", id: "skPiz3GrVNs",
name: "LIVE Keysight Scope Draw #2", name: "LIVE Keysight Scope Draw #2",
duration: Some(2445), length: Some(2445),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/skPiz3GrVNs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFiIfUBfoL0Q9CLR9Pc8bXy-zclg", url: "https://i.ytimg.com/vi/skPiz3GrVNs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFiIfUBfoL0Q9CLR9Pc8bXy-zclg",
@ -330,7 +383,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -344,7 +397,7 @@ Channel(
VideoItem( VideoItem(
id: "HZc-Ctvgv5Y", id: "HZc-Ctvgv5Y",
name: "LIVE Keysight Scope Draw", name: "LIVE Keysight Scope Draw",
duration: Some(6455), length: Some(6455),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/HZc-Ctvgv5Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQM1_QPh6u5_BFonLCdFPz-AcpkQ", url: "https://i.ytimg.com/vi/HZc-Ctvgv5Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQM1_QPh6u5_BFonLCdFPz-AcpkQ",
@ -371,7 +424,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -385,7 +438,7 @@ Channel(
VideoItem( VideoItem(
id: "5ilODYy2zGE", id: "5ilODYy2zGE",
name: "Ask Dave LIVE - March 8th 2019", name: "Ask Dave LIVE - March 8th 2019",
duration: Some(10645), length: Some(10645),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/5ilODYy2zGE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCft4f7Lw3l3_u55bzUibWXr-UHTQ", url: "https://i.ytimg.com/vi/5ilODYy2zGE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCft4f7Lw3l3_u55bzUibWXr-UHTQ",
@ -412,7 +465,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -426,7 +479,7 @@ Channel(
VideoItem( VideoItem(
id: "gQ7TTuiDH1M", id: "gQ7TTuiDH1M",
name: "Ask Dave LIVE - Jan 28th 2019", name: "Ask Dave LIVE - Jan 28th 2019",
duration: Some(17228), length: Some(17228),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/gQ7TTuiDH1M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAUPZz1xzckl5xzdBRonA_1WNWIyg", url: "https://i.ytimg.com/vi/gQ7TTuiDH1M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAUPZz1xzckl5xzdBRonA_1WNWIyg",
@ -453,7 +506,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -467,7 +520,7 @@ Channel(
VideoItem( VideoItem(
id: "qpw9dKxL2Ho", id: "qpw9dKxL2Ho",
name: "LIVE KiCAD 5 PCB Design", name: "LIVE KiCAD 5 PCB Design",
duration: Some(8003), length: Some(8003),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/qpw9dKxL2Ho/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAC-kI2770I7JgVCTYExG0vXoYoxA", url: "https://i.ytimg.com/vi/qpw9dKxL2Ho/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAC-kI2770I7JgVCTYExG0vXoYoxA",
@ -494,7 +547,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -508,7 +561,7 @@ Channel(
VideoItem( VideoItem(
id: "wECZoUNd2GY", id: "wECZoUNd2GY",
name: "EEVblog LIVE DIY TTL Computer Build", name: "EEVblog LIVE DIY TTL Computer Build",
duration: Some(14599), length: Some(14599),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/wECZoUNd2GY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDzZwAD6bQQEaYuZEzmQ0sgQKc1yA", url: "https://i.ytimg.com/vi/wECZoUNd2GY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDzZwAD6bQQEaYuZEzmQ0sgQKc1yA",
@ -535,7 +588,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -549,7 +602,7 @@ Channel(
VideoItem( VideoItem(
id: "bV99dn-tWDk", id: "bV99dn-tWDk",
name: "EEVblog LIVE Scope Draw", name: "EEVblog LIVE Scope Draw",
duration: Some(2694), length: Some(2694),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/bV99dn-tWDk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAR4ckJxAituVMFCyWpYhHXozqQRA", url: "https://i.ytimg.com/vi/bV99dn-tWDk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAR4ckJxAituVMFCyWpYhHXozqQRA",
@ -576,7 +629,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -590,7 +643,7 @@ Channel(
VideoItem( VideoItem(
id: "-NGRIFiu_p0", id: "-NGRIFiu_p0",
name: "EEVblog LIVE SHOW - End of 2017", name: "EEVblog LIVE SHOW - End of 2017",
duration: Some(12238), length: Some(12238),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/-NGRIFiu_p0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjMmIdgjiSMBQ2X73h6-NtVUIqSg", url: "https://i.ytimg.com/vi/-NGRIFiu_p0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjMmIdgjiSMBQ2X73h6-NtVUIqSg",
@ -617,7 +670,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -631,7 +684,7 @@ Channel(
VideoItem( VideoItem(
id: "zgE6_x4rM5k", id: "zgE6_x4rM5k",
name: "LIVE Show Giveaway", name: "LIVE Show Giveaway",
duration: Some(5533), length: Some(5533),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/zgE6_x4rM5k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjb92wUNqOvTKs9TCLCThvdkdz3A", url: "https://i.ytimg.com/vi/zgE6_x4rM5k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjb92wUNqOvTKs9TCLCThvdkdz3A",
@ -658,7 +711,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -672,7 +725,7 @@ Channel(
VideoItem( VideoItem(
id: "9DjABCJN2M8", id: "9DjABCJN2M8",
name: "LIVE Testing of the Batteriser", name: "LIVE Testing of the Batteriser",
duration: Some(10747), length: Some(10747),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/9DjABCJN2M8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBXhnnHCuNfSzHZC64KFsfHPPJDNg", url: "https://i.ytimg.com/vi/9DjABCJN2M8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBXhnnHCuNfSzHZC64KFsfHPPJDNg",
@ -699,7 +752,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -713,7 +766,7 @@ Channel(
VideoItem( VideoItem(
id: "cAsUI2YhqN4", id: "cAsUI2YhqN4",
name: "LIVE Unboxing of the Batteriser! (Batteroo)", name: "LIVE Unboxing of the Batteriser! (Batteroo)",
duration: Some(3102), length: Some(3102),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/cAsUI2YhqN4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCOE1MyG1nFXs9D2qdK78bpN1mc_g", url: "https://i.ytimg.com/vi/cAsUI2YhqN4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCOE1MyG1nFXs9D2qdK78bpN1mc_g",
@ -740,7 +793,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -754,7 +807,7 @@ Channel(
VideoItem( VideoItem(
id: "CLYKwFMW9J0", id: "CLYKwFMW9J0",
name: "Juno Live Again", name: "Juno Live Again",
duration: Some(811), length: Some(811),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/CLYKwFMW9J0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC7WO4HX0e7M58ddoJD5dkVjdKHYQ", url: "https://i.ytimg.com/vi/CLYKwFMW9J0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC7WO4HX0e7M58ddoJD5dkVjdKHYQ",
@ -781,7 +834,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -795,7 +848,7 @@ Channel(
VideoItem( VideoItem(
id: "nV43vM9VcUA", id: "nV43vM9VcUA",
name: "Juno Live", name: "Juno Live",
duration: Some(190), length: Some(190),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/nV43vM9VcUA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy-zEVPDvomCCi8YoP8Ig_Hrhzfw", url: "https://i.ytimg.com/vi/nV43vM9VcUA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy-zEVPDvomCCi8YoP8Ig_Hrhzfw",
@ -822,7 +875,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -836,7 +889,7 @@ Channel(
VideoItem( VideoItem(
id: "38uFiWzcDnc", id: "38uFiWzcDnc",
name: "Juno Orbital Insertion Live", name: "Juno Orbital Insertion Live",
duration: Some(1731), length: Some(1731),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/38uFiWzcDnc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLALhrDygxFH4T2c-4efZqVaJnYY7g", url: "https://i.ytimg.com/vi/38uFiWzcDnc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLALhrDygxFH4T2c-4efZqVaJnYY7g",
@ -863,7 +916,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -877,7 +930,7 @@ Channel(
VideoItem( VideoItem(
id: "ib80yjc9VlM", id: "ib80yjc9VlM",
name: "Juno Jupiter Live", name: "Juno Jupiter Live",
duration: Some(581), length: Some(581),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/ib80yjc9VlM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDbJJvzoEmwUc7nAm6GLJpoZJKmgQ", url: "https://i.ytimg.com/vi/ib80yjc9VlM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDbJJvzoEmwUc7nAm6GLJpoZJKmgQ",
@ -904,7 +957,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -918,7 +971,7 @@ Channel(
VideoItem( VideoItem(
id: "rQRakYpb8-g", id: "rQRakYpb8-g",
name: "eevSTREAM: Lab Rearrangement Part 2", name: "eevSTREAM: Lab Rearrangement Part 2",
duration: Some(8616), length: Some(8616),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/rQRakYpb8-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAdGJH0yhCQ7kmI3d3JXVv_7xzJAQ", url: "https://i.ytimg.com/vi/rQRakYpb8-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAdGJH0yhCQ7kmI3d3JXVv_7xzJAQ",
@ -945,7 +998,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -959,7 +1012,7 @@ Channel(
VideoItem( VideoItem(
id: "DwLEFKu2XWg", id: "DwLEFKu2XWg",
name: "eevSTREAM: Lab Rearrangement Part 1", name: "eevSTREAM: Lab Rearrangement Part 1",
duration: Some(768), length: Some(768),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/DwLEFKu2XWg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCXvSePgZ8NIKQTviqWvROVZFRPpA", url: "https://i.ytimg.com/vi/DwLEFKu2XWg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCXvSePgZ8NIKQTviqWvROVZFRPpA",
@ -986,7 +1039,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1000,7 +1053,7 @@ Channel(
VideoItem( VideoItem(
id: "VeUDXQR3F2o", id: "VeUDXQR3F2o",
name: "Live Show", name: "Live Show",
duration: Some(10360), length: Some(10360),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/VeUDXQR3F2o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDmgrfQXMTaGMahuP8F_UHJAomFbg", url: "https://i.ytimg.com/vi/VeUDXQR3F2o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDmgrfQXMTaGMahuP8F_UHJAomFbg",
@ -1027,7 +1080,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1041,7 +1094,7 @@ Channel(
VideoItem( VideoItem(
id: "PgZx25vVwoI", id: "PgZx25vVwoI",
name: "Live Giveaway", name: "Live Giveaway",
duration: Some(1808), length: Some(1808),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/PgZx25vVwoI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDTrMmoCfISxG0YSqC4oEyKGHdK_A", url: "https://i.ytimg.com/vi/PgZx25vVwoI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDTrMmoCfISxG0YSqC4oEyKGHdK_A",
@ -1068,7 +1121,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1082,7 +1135,7 @@ Channel(
VideoItem( VideoItem(
id: "jUtzoO-ur34", id: "jUtzoO-ur34",
name: "Inventables X-Carve LIVE Build Part 4", name: "Inventables X-Carve LIVE Build Part 4",
duration: Some(10665), length: Some(10665),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/jUtzoO-ur34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCO35sFP8D_Q08HxMZkNHFO8MmpDg", url: "https://i.ytimg.com/vi/jUtzoO-ur34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCO35sFP8D_Q08HxMZkNHFO8MmpDg",
@ -1109,7 +1162,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1123,7 +1176,7 @@ Channel(
VideoItem( VideoItem(
id: "199gtbX1y4M", id: "199gtbX1y4M",
name: "Inventables X-Carve LIVE Build Part 3 + Batteriser Rant", name: "Inventables X-Carve LIVE Build Part 3 + Batteriser Rant",
duration: Some(6267), length: Some(6267),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/199gtbX1y4M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg3bMS00xpSXmNn1f5hXu_jWWC1w", url: "https://i.ytimg.com/vi/199gtbX1y4M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg3bMS00xpSXmNn1f5hXu_jWWC1w",
@ -1150,7 +1203,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1164,7 +1217,7 @@ Channel(
VideoItem( VideoItem(
id: "nQH4I_p7-MI", id: "nQH4I_p7-MI",
name: "Inventables X-Carve LIVE Build Part 2", name: "Inventables X-Carve LIVE Build Part 2",
duration: Some(17643), length: Some(17643),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/nQH4I_p7-MI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBMIA1YzQefFwGj5UFikXuYS2Nkng", url: "https://i.ytimg.com/vi/nQH4I_p7-MI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBMIA1YzQefFwGj5UFikXuYS2Nkng",
@ -1191,7 +1244,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1205,7 +1258,7 @@ Channel(
VideoItem( VideoItem(
id: "XBMNFXGKpaw", id: "XBMNFXGKpaw",
name: "Inventables X-Carve LIVE Build", name: "Inventables X-Carve LIVE Build",
duration: Some(5479), length: Some(5479),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/XBMNFXGKpaw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCV980wWO8tdx0aFDXwPn9aBQ2xlA", url: "https://i.ytimg.com/vi/XBMNFXGKpaw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCV980wWO8tdx0aFDXwPn9aBQ2xlA",
@ -1232,7 +1285,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1246,7 +1299,7 @@ Channel(
VideoItem( VideoItem(
id: "yl6DGgiE3J8", id: "yl6DGgiE3J8",
name: "Apollo Saturn LVDC Live testing", name: "Apollo Saturn LVDC Live testing",
duration: Some(1076), length: Some(1076),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/yl6DGgiE3J8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCugABHuqqPZQjV9cEm0JFh7R5aiA", url: "https://i.ytimg.com/vi/yl6DGgiE3J8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCugABHuqqPZQjV9cEm0JFh7R5aiA",
@ -1273,7 +1326,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1287,7 +1340,7 @@ Channel(
VideoItem( VideoItem(
id: "EEMcIZAcKjc", id: "EEMcIZAcKjc",
name: "LIVE EEVblog Mailbag", name: "LIVE EEVblog Mailbag",
duration: Some(7344), length: Some(7344),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/EEMcIZAcKjc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCg16HpJqC9mNwkYOf8b0cfAuNLOA", url: "https://i.ytimg.com/vi/EEMcIZAcKjc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCg16HpJqC9mNwkYOf8b0cfAuNLOA",
@ -1314,7 +1367,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(884000), subscriber_count: Some(884000),
)), )),
publish_date: "[date]", publish_date: "[date]",

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
handle: None,
subscriber_count: Some(881000), subscriber_count: Some(881000),
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
@ -25,7 +23,7 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: verified, verification: Verified,
description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA",
tags: [ tags: [
"electronics", "electronics",
@ -57,6 +55,7 @@ Channel(
"dumpster diving", "dumpster diving",
"debunking", "debunking",
], ],
vanity_url: Some("https://www.youtube.com/c/EevblogDave"),
banner: [ banner: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
@ -89,6 +88,60 @@ Channel(
height: 424, height: 424,
), ),
], ],
mobile_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: false, has_shorts: false,
has_live: false, has_live: false,
visitor_data: Some("CgttaWpyTVpUN1AyZyioqr2ZBg%3D%3D"), visitor_data: Some("CgttaWpyTVpUN1AyZyioqr2ZBg%3D%3D"),
@ -109,7 +162,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(2), video_count: Some(2),
@ -128,7 +181,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(1), video_count: Some(1),
@ -147,7 +200,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(9), video_count: Some(9),
@ -166,7 +219,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(2), video_count: Some(2),
@ -185,7 +238,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(4), video_count: Some(4),
@ -204,7 +257,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(18), video_count: Some(18),
@ -223,7 +276,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(3), video_count: Some(3),
@ -242,7 +295,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(8), video_count: Some(8),
@ -261,7 +314,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(13), video_count: Some(13),
@ -280,7 +333,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(9), video_count: Some(9),
@ -299,7 +352,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(7), video_count: Some(7),
@ -318,7 +371,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(3), video_count: Some(3),
@ -337,7 +390,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(8), video_count: Some(8),
@ -356,7 +409,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(2), video_count: Some(2),
@ -375,7 +428,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(3), video_count: Some(3),
@ -394,7 +447,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(10), video_count: Some(10),
@ -413,7 +466,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(1), video_count: Some(1),
@ -432,7 +485,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(9), video_count: Some(9),
@ -451,7 +504,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(16), video_count: Some(16),
@ -470,7 +523,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(7), video_count: Some(7),
@ -489,7 +542,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(6), video_count: Some(6),
@ -508,7 +561,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(12), video_count: Some(12),
@ -527,7 +580,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(1), video_count: Some(1),
@ -546,7 +599,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(5), video_count: Some(5),
@ -565,7 +618,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(2), video_count: Some(2),
@ -584,7 +637,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(4), video_count: Some(4),
@ -603,7 +656,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(1), video_count: Some(1),
@ -622,7 +675,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(2), video_count: Some(2),
@ -641,7 +694,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(9), video_count: Some(9),
@ -660,7 +713,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(881000), subscriber_count: Some(881000),
)), )),
video_count: Some(1), video_count: Some(1),

View file

@ -1,672 +0,0 @@
---
source: src/client/channel.rs
expression: map_res.c
---
Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
handle: Some("@EEVblog"),
subscriber_count: Some(952000),
video_count: Some(2),
avatar: [
Thumbnail(
url: "https://yt3.googleusercontent.com/ytc/AIdro_l17lYcTcRSydZeQK-RuiSfEeH5eX9m4irSNQj6109v5MQ=s72-c-k-c0x00ffffff-no-rj",
width: 72,
height: 72,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/ytc/AIdro_l17lYcTcRSydZeQK-RuiSfEeH5eX9m4irSNQj6109v5MQ=s120-c-k-c0x00ffffff-no-rj",
width: 120,
height: 120,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/ytc/AIdro_l17lYcTcRSydZeQK-RuiSfEeH5eX9m4irSNQj6109v5MQ=s160-c-k-c0x00ffffff-no-rj",
width: 160,
height: 160,
),
],
verification: verified,
description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA",
tags: [
"electronics",
"engineering",
"maker",
"hacker",
"design",
"circuit",
"hardware",
"pic",
"atmel",
"oscilloscope",
"multimeter",
"diy",
"hobby",
"review",
"teardown",
"microcontroller",
"arduino",
"video",
"blog",
"tutorial",
"how-to",
"interview",
"rant",
"industry",
"news",
"mailbag",
"dumpster diving",
"debunking",
],
banner: [
Thumbnail(
url: "https://yt3.googleusercontent.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1060,
height: 175,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1138,
height: 188,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1707,
height: 283,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 351,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2276,
height: 377,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2560,
height: 424,
),
],
has_shorts: true,
has_live: true,
visitor_data: None,
content: Paginator(
count: None,
items: [
PlaylistItem(
id: "PLvOlSehNtuHv268f0mW5m1t_hq_RVGRSA",
name: "Jellybean Components Series",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/XYdmX8w8xwI/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCqmf6TGfDinNXhgU29ZxOkv2u9sQ",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(5),
),
PlaylistItem(
id: "PLvOlSehNtuHu46I7nFuUg3LC3PpiWTR4f",
name: "Tandy Electronics / Radio Shack & Computers",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/uUXxY6gA-7g/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAlIVvQ4Axx40Xa_i8F56qmppXEXg",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(11),
),
PlaylistItem(
id: "PLvOlSehNtuHuS01_RNCnvpzyk7bycYCmM",
name: "Open Source Hardware",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/m_8jh_MpWBE/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBx6U5iikp5rSO78dIWdy1RQ_BLNQ",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(4),
),
PlaylistItem(
id: "PLvOlSehNtuHuwwQ1fpquOJuA5MSfD4iD6",
name: "Fluke Multimeters",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/ymJc5oxthlw/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLDAOiw39aJajjAdroLnuj_fh60Ryw",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(22),
),
PlaylistItem(
id: "PLvOlSehNtuHs2LwEdDwTp3n7mxb-MyBbo",
name: "EEVacademy Digital Design Tutorial Series",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/lJ3q9RHIatU/hqdefault.jpg?sqp=-oaymwExCOADEI4CSFryq4qpAyMIARUAAIhCGAHwAQH4Af4JgALQBYoCDAgAEAEYQyBXKGUwDw==&rs=AOn4CLBaaQaTJzi7H-zjwSsTlNJdBsyqvQ",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(5),
),
PlaylistItem(
id: "PLvOlSehNtuHu2v8THrRMt8E9ziHtRXPm7",
name: "AI / ChatGPT",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/g5_Ts9SWbYs/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBmZPW6EiAvTCsI86BFg4BxXLj66A",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(3),
),
PlaylistItem(
id: "PLvOlSehNtuHvXuXRmoBUys09Dwi1heNii",
name: "Shorts",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/ndvJtQ8nxV4/hqdefault.jpg?sqp=-oaymwExCOADEI4CSFryq4qpAyMIARUAAIhCGAHwAQH4AbYIgAKAD4oCDAgAEAEYNyBTKH8wDw==&rs=AOn4CLDD0qOLs38KPJtqdG6zCeVLQMf62Q",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(1),
),
PlaylistItem(
id: "PLvOlSehNtuHv3gxNg5BGoZJJu9htoAGB6",
name: "Microcontrollers",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/L9Wrv7nW-S8/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLDiAT5izyig1ntMSUhvSOVuYSsG1Q",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(1),
),
PlaylistItem(
id: "PLvOlSehNtuHvllTQ-vwvY26E3Bvrov93Y",
name: "Bypass Capacitors",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/1xicZF9glH0/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAFb2FcbpdtAG1xLjmdkdIm1hFvgA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(4),
),
PlaylistItem(
id: "PLvOlSehNtuHtOV3AEwhuea4TnviddKfAj",
name: "MacGyver Project",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/4yosozyeIP4/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAkwsCiJjFkWhYxtcg5NgfnQbkZrA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(3),
),
PlaylistItem(
id: "PLvOlSehNtuHuvHE5GQrQJxWXHdmW2l5IF",
name: "Calculators",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/S3R4r2xvVYQ/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLB7HH5drG-33c1SyRe9kyZBrXvm3A",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(1),
),
PlaylistItem(
id: "PLvOlSehNtuHs6wRwVSaErU0BEnLiHfnKJ",
name: "BM235",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/WPyEFB4cHkA/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAzBuQFV8T9hM8adlPvv58C9TeDug",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(9),
),
PlaylistItem(
id: "PLvOlSehNtuHu4k0ZkKFLsysSB5iava6Qu",
name: "Vibration Measurement",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/uus_cpZiqsU/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCqdsjWVFaLOkEcXgbZD2Eca8MnuQ",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(3),
),
PlaylistItem(
id: "PLvOlSehNtuHtdQF-m5UFZ5GEjABadI3kI",
name: "Component Selection",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/uq1DMWtjL2U/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAbgb1Jdb5P69JGdZQ-a8asLLyYdA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(6),
),
PlaylistItem(
id: "PLvOlSehNtuHtlndPUSOPgsujUdq1c5Mr9",
name: "Solar Roadways",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/oIImmlfCyzo/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBxApgyGu3dNXRGoqLctVUnESpEIA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(23),
),
PlaylistItem(
id: "PLvOlSehNtuHvD6M_7WeN071OVsZFE0_q-",
name: "Electronics Tutorials - AC Circuit Theory Series",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/rrPtvYYJ2-g/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBEVc71xxSjJ-xlA_dDQaYIjdHyUw",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(3),
),
PlaylistItem(
id: "PLvOlSehNtuHtVLq2MDPIz82BWMIZcuwhK",
name: "Electronics Tutorial - DC Fundamentals",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/xSRe_4TQbuo/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLDP4V24_MG6vzvUZsHep9WFSCCY6Q",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(8),
),
PlaylistItem(
id: "PLvOlSehNtuHvIDfW3x2p4BY6l4RYgfBJE",
name: "Oscilloscope Probing",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/OiAmER1OJh4/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAXeGAvEc8y3pEsPUxWdsNIP9UmPw",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(14),
),
PlaylistItem(
id: "PLvOlSehNtuHu6Jjb8U82eKQfvKhJVl0Bu",
name: "Thermal Design",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/8ruFVmxf0zs/hqdefault.jpg?sqp=-oaymwExCOADEI4CSFryq4qpAyMIARUAAIhCGAHwAQH4Af4JgALQBYoCDAgAEAEYfyA1KDUwDw==&rs=AOn4CLD6PMawyYXKe8KT1-Y6vWjQc2xIDw",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(9),
),
PlaylistItem(
id: "PLvOlSehNtuHs-X2Awg33PCBNrP2BGFVhC",
name: "Electric Cars",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/CPcZm1Tu5VI/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCsm8De0QaHPaeCZqxMp_F464fWzg",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(9),
),
PlaylistItem(
id: "PLvOlSehNtuHuLODLTeq3PM-OJRP2nzNUa",
name: "Designing a better uCurrent",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/0AEVilxXAAo/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCjotFuRjPPBHd2LWzt3lviPj9HaA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(3),
),
PlaylistItem(
id: "PLvOlSehNtuHtvTKP4RTNW1-08Kmzy1pvA",
name: "EMC Compliance & Measurement",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/lYmfVMWbIHQ/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBtygEqMXx7Lwe5SuBWt2q0CSahYA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(8),
),
PlaylistItem(
id: "PLvOlSehNtuHuUTpCrTVX7BdU68l2aVqMv",
name: "Power Counter Display Project",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/nTpE1Nw3Yy4/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAbPl28_i7isizY6A1t2_c6gV8BAQ",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(2),
),
PlaylistItem(
id: "PLvOlSehNtuHvm120Tq40nKrM5SUBlolN3",
name: "Live - Ask Dave",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/gQ7TTuiDH1M/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBMnucUil90WeDSIeFz8mZCOtEv9g",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(3),
),
PlaylistItem(
id: "PLvOlSehNtuHsiF93KOLoF1KAHArmIW9lC",
name: "Padauk Microcontroller",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/r45r4rV5JOI/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCn4kGWcjBOhk3vN8QPMDa9L3mkKA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(10),
),
PlaylistItem(
id: "PLvOlSehNtuHvxTzBLwUFw4My4rtrNFzED",
name: "Other Debunking Videos",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/WopuF9vD7KE/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBv5buh3qMs4feQaPj6Fy6bxl_vuA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(1),
),
PlaylistItem(
id: "PLvOlSehNtuHt2pJ7X5tumuM4Wa3r1OC7Q",
name: "Audio & Speakers",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/qHbkw0Gm7pk/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCJBYXTDttGHTm51j3bfwqxOqVFig",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(9),
),
PlaylistItem(
id: "PLvOlSehNtuHtX7OearWdmqGzqiu4DHKWi",
name: "Cameras",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/g9umAQ1-an4/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCB5jNm9U-rypnpthK_N321LpYWew",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(16),
),
PlaylistItem(
id: "PLvOlSehNtuHu-TaNRp27_PiXjBG5wY9Gv",
name: "Cryptocurrency",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/ibPgfzd9zd8/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLDe3IXT88HR3XxnxfqrpAxh6pfYMg",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(7),
),
PlaylistItem(
id: "PLvOlSehNtuHvmK-VGcZ33ZuATmcNB8tvH",
name: "LCD Tutorial",
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/ZYvxgl-9tNM/hqdefault.jpg?sqp=-oaymwEXCOADEI4CSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLDv2WT4Chl1_H2G43AjfSFpPcKVoA",
width: 480,
height: 270,
),
],
channel: Some(ChannelTag(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
avatar: [],
verification: verified,
subscriber_count: Some(952000),
)),
video_count: Some(6),
),
],
ctoken: Some("4qmFsgLCARIYVUMyRGpGRTdYZjExVVJacVdCaWdjVk9RGnRFZ2x3YkdGNWJHbHpkSE1ZQXlBQk1BRTRBZW9EUEVOblRrUlJhbEZUU2tKSmFWVkZlREpVTW5oVVdsZG9UMlJJVmtsa2JURk1URlphU0ZreGIzcE5NWEF4VVZaU2RGa3dOVU5QU0ZJeVUwTm5PQSUzRCUzRJoCL2Jyb3dzZS1mZWVkVUMyRGpGRTdYZjExVVJacVdCaWdjVk9RcGxheWxpc3RzMTA0"),
endpoint: browse,
),
)

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
handle: Some("@Doobydobap"),
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "https://yt3.googleusercontent.com/dm5Aq93xvVJz0NoVO88ieBkDXmuShCujGPlZ7qETMEPTrXvPUCFI3-BB6Xs_P-r6Uk3mnBy9zA=s48-c-k-c0x00ffffff-no-rj", url: "https://yt3.googleusercontent.com/dm5Aq93xvVJz0NoVO88ieBkDXmuShCujGPlZ7qETMEPTrXvPUCFI3-BB6Xs_P-r6Uk3mnBy9zA=s48-c-k-c0x00ffffff-no-rj",
@ -25,9 +23,10 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: verified, verification: Verified,
description: "Hi, Im Tina, aka Doobydobap!\n\nFood is the medium I use to tell stories and connect with people who share the same passion as I do. Whether its because youre hungry at midnight or trying to learn how to cook, I hope you enjoy watching my content and recipes. Don\'t yuck my yum!\n\nwww.doobydobap.com\n", description: "Hi, Im Tina, aka Doobydobap!\n\nFood is the medium I use to tell stories and connect with people who share the same passion as I do. Whether its because youre hungry at midnight or trying to learn how to cook, I hope you enjoy watching my content and recipes. Don\'t yuck my yum!\n\nwww.doobydobap.com\n",
tags: [], tags: [],
vanity_url: Some("https://www.youtube.com/@Doobydobap"),
banner: [ banner: [
Thumbnail( Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
@ -60,6 +59,60 @@ Channel(
height: 424, height: 424,
), ),
], ],
mobile_banner: [
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: true, has_shorts: true,
has_live: false, has_live: false,
visitor_data: Some("CgtHU1dvWkR4cGRfdyjMpt6iBg%3D%3D"), visitor_data: Some("CgtHU1dvWkR4cGRfdyjMpt6iBg%3D%3D"),
@ -69,7 +122,7 @@ Channel(
VideoItem( VideoItem(
id: "bGXP83AU3Mc", id: "bGXP83AU3Mc",
name: "do u wanna get swole?", name: "do u wanna get swole?",
duration: None, length: Some(53),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/bGXP83AU3Mc/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC9bzCBeHDbZFLE84Up3IiBIsxmmA", url: "https://i.ytimg.com/vi/bGXP83AU3Mc/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC9bzCBeHDbZFLE84Up3IiBIsxmmA",
@ -81,7 +134,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -95,7 +148,7 @@ Channel(
VideoItem( VideoItem(
id: "E52sSgZlgYs", id: "E52sSgZlgYs",
name: "the holy trinity of korean street food", name: "the holy trinity of korean street food",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/E52sSgZlgYs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDBahtFRcfBInHuA8CjXFPWkF2jHg", url: "https://i.ytimg.com/vi/E52sSgZlgYs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDBahtFRcfBInHuA8CjXFPWkF2jHg",
@ -107,7 +160,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -121,7 +174,7 @@ Channel(
VideoItem( VideoItem(
id: "ovaHmfy3O6U", id: "ovaHmfy3O6U",
name: "hangover food", name: "hangover food",
duration: None, length: Some(58),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/ovaHmfy3O6U/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCHmvWlG06h-DT6oxfmh69JGQ69KA", url: "https://i.ytimg.com/vi/ovaHmfy3O6U/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCHmvWlG06h-DT6oxfmh69JGQ69KA",
@ -133,7 +186,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -147,7 +200,7 @@ Channel(
VideoItem( VideoItem(
id: "FHTQmKTZnlI", id: "FHTQmKTZnlI",
name: "pig trotter raguuuuuuuuu 💅", name: "pig trotter raguuuuuuuuu 💅",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/FHTQmKTZnlI/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD0xhka1osA4nI3VCwhQusn3ND3Hg", url: "https://i.ytimg.com/vi/FHTQmKTZnlI/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD0xhka1osA4nI3VCwhQusn3ND3Hg",
@ -159,7 +212,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -173,7 +226,7 @@ Channel(
VideoItem( VideoItem(
id: "1AXB0l_wKMs", id: "1AXB0l_wKMs",
name: "what i ate in google japan", name: "what i ate in google japan",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/1AXB0l_wKMs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBsfYJ0KffUNn-9jBzNRTqetyFr8g", url: "https://i.ytimg.com/vi/1AXB0l_wKMs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBsfYJ0KffUNn-9jBzNRTqetyFr8g",
@ -185,7 +238,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -199,7 +252,7 @@ Channel(
VideoItem( VideoItem(
id: "1ARLtk3HiB0", id: "1ARLtk3HiB0",
name: "succumb to your cravings", name: "succumb to your cravings",
duration: None, length: Some(53),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/1ARLtk3HiB0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBY9E40Ehvq862CVItJy0Uj_pS5bg", url: "https://i.ytimg.com/vi/1ARLtk3HiB0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBY9E40Ehvq862CVItJy0Uj_pS5bg",
@ -211,7 +264,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -225,7 +278,7 @@ Channel(
VideoItem( VideoItem(
id: "0FfDoDHpaN8", id: "0FfDoDHpaN8",
name: "you can\'t let the what ifs rule your life", name: "you can\'t let the what ifs rule your life",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/0FfDoDHpaN8/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBiV2TVPO-VbIjoNtwCKmFuxmj6LA", url: "https://i.ytimg.com/vi/0FfDoDHpaN8/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBiV2TVPO-VbIjoNtwCKmFuxmj6LA",
@ -237,7 +290,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -251,7 +304,7 @@ Channel(
VideoItem( VideoItem(
id: "kuT90_RIdF0", id: "kuT90_RIdF0",
name: "duck confit lollipop 🦆🍭", name: "duck confit lollipop 🦆🍭",
duration: None, length: Some(59),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/kuT90_RIdF0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCUN-DW72m7sAXJMgVkWNxPYpJBcQ", url: "https://i.ytimg.com/vi/kuT90_RIdF0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCUN-DW72m7sAXJMgVkWNxPYpJBcQ",
@ -263,7 +316,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -277,7 +330,7 @@ Channel(
VideoItem( VideoItem(
id: "aPJLhrcM4Yg", id: "aPJLhrcM4Yg",
name: "HOUSE TOUR", name: "HOUSE TOUR",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/aPJLhrcM4Yg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD1TbWAIbzyWq8AXLoW0xqaji3ukQ", url: "https://i.ytimg.com/vi/aPJLhrcM4Yg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD1TbWAIbzyWq8AXLoW0xqaji3ukQ",
@ -289,7 +342,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -303,7 +356,7 @@ Channel(
VideoItem( VideoItem(
id: "DKQrG_hJJX4", id: "DKQrG_hJJX4",
name: "how to meal prep like a korean", name: "how to meal prep like a korean",
duration: None, length: Some(59),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/DKQrG_hJJX4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBE2DnpLFvtXsZOu1Ta4JQeOToVAw", url: "https://i.ytimg.com/vi/DKQrG_hJJX4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBE2DnpLFvtXsZOu1Ta4JQeOToVAw",
@ -315,7 +368,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -329,7 +382,7 @@ Channel(
VideoItem( VideoItem(
id: "lNizW_P_oVw", id: "lNizW_P_oVw",
name: "Rating Everything I ate at McDonald\'s Japan 🇯🇵", name: "Rating Everything I ate at McDonald\'s Japan 🇯🇵",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/lNizW_P_oVw/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBI5XrkQ9Hesbf4lWELy7Uk3yMGMg", url: "https://i.ytimg.com/vi/lNizW_P_oVw/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBI5XrkQ9Hesbf4lWELy7Uk3yMGMg",
@ -341,7 +394,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -355,7 +408,7 @@ Channel(
VideoItem( VideoItem(
id: "kbWyJjrCjwA", id: "kbWyJjrCjwA",
name: "enemies as fertilizer √(veg)", name: "enemies as fertilizer √(veg)",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/kbWyJjrCjwA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDlk30Km1M0jze1M3O90fB2LdvoAQ", url: "https://i.ytimg.com/vi/kbWyJjrCjwA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDlk30Km1M0jze1M3O90fB2LdvoAQ",
@ -367,7 +420,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -381,7 +434,7 @@ Channel(
VideoItem( VideoItem(
id: "xAp910JTDig", id: "xAp910JTDig",
name: "let\'s make some cabbage rolls for lunch", name: "let\'s make some cabbage rolls for lunch",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/xAp910JTDig/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAJtpPGRgffBu9WDXACbtiGa_oRgA", url: "https://i.ytimg.com/vi/xAp910JTDig/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAJtpPGRgffBu9WDXACbtiGa_oRgA",
@ -393,7 +446,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -407,7 +460,7 @@ Channel(
VideoItem( VideoItem(
id: "vSL7dhKatEk", id: "vSL7dhKatEk",
name: "Rating Everything I ate at IKEA Korea", name: "Rating Everything I ate at IKEA Korea",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/vSL7dhKatEk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBYpIDYbwwWiCqRNVi6PlfEfjrt4A", url: "https://i.ytimg.com/vi/vSL7dhKatEk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBYpIDYbwwWiCqRNVi6PlfEfjrt4A",
@ -419,7 +472,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -433,7 +486,7 @@ Channel(
VideoItem( VideoItem(
id: "LZzhUpACXSk", id: "LZzhUpACXSk",
name: "I\'m done being the bigger person", name: "I\'m done being the bigger person",
duration: None, length: Some(59),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/LZzhUpACXSk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAFTvhtVUP7QZ4P7U70-0XH7PzDDg", url: "https://i.ytimg.com/vi/LZzhUpACXSk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAFTvhtVUP7QZ4P7U70-0XH7PzDDg",
@ -445,7 +498,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -459,7 +512,7 @@ Channel(
VideoItem( VideoItem(
id: "5C7nqNDfhis", id: "5C7nqNDfhis",
name: "we\'re cooking a whole bird today", name: "we\'re cooking a whole bird today",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/5C7nqNDfhis/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLA9I9irDsRjikwd0aqp1FWNFtjAqA", url: "https://i.ytimg.com/vi/5C7nqNDfhis/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLA9I9irDsRjikwd0aqp1FWNFtjAqA",
@ -471,7 +524,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -485,7 +538,7 @@ Channel(
VideoItem( VideoItem(
id: "6mj4Af0kUOQ", id: "6mj4Af0kUOQ",
name: "men will disappoint but never potatoes", name: "men will disappoint but never potatoes",
duration: None, length: Some(50),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/6mj4Af0kUOQ/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAVxl-FPt878AQXPBhbV1VSGeR8sw", url: "https://i.ytimg.com/vi/6mj4Af0kUOQ/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAVxl-FPt878AQXPBhbV1VSGeR8sw",
@ -497,7 +550,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -511,7 +564,7 @@ Channel(
VideoItem( VideoItem(
id: "1c3axhSJiaQ", id: "1c3axhSJiaQ",
name: "I used to hate korean food", name: "I used to hate korean food",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/1c3axhSJiaQ/hq720_2.jpg?sqp=-oaymwEdCJUDENAFSFXyq4qpAw8IARUAAIhCcAHAAQbQAQE=&rs=AOn4CLBucOEbTsWTDjOOCjNa-fAvz1yxyA", url: "https://i.ytimg.com/vi/1c3axhSJiaQ/hq720_2.jpg?sqp=-oaymwEdCJUDENAFSFXyq4qpAw8IARUAAIhCcAHAAQbQAQE=&rs=AOn4CLBucOEbTsWTDjOOCjNa-fAvz1yxyA",
@ -523,7 +576,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -537,7 +590,7 @@ Channel(
VideoItem( VideoItem(
id: "F9Vz0m7DPeU", id: "F9Vz0m7DPeU",
name: "Rating everything I got at 7/11 Hawaii ( ft. Mauna Kea )", name: "Rating everything I got at 7/11 Hawaii ( ft. Mauna Kea )",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/F9Vz0m7DPeU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDOoCVL6la3ztUeQ6vP4iL1cEBRjQ", url: "https://i.ytimg.com/vi/F9Vz0m7DPeU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDOoCVL6la3ztUeQ6vP4iL1cEBRjQ",
@ -549,7 +602,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -563,7 +616,7 @@ Channel(
VideoItem( VideoItem(
id: "Uey7kl56wks", id: "Uey7kl56wks",
name: "Grabbing Snacks from 7/11 Hawaii", name: "Grabbing Snacks from 7/11 Hawaii",
duration: None, length: Some(49),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Uey7kl56wks/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCWmgajinNtIEbiPbqEtDvkC7Ydrg", url: "https://i.ytimg.com/vi/Uey7kl56wks/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCWmgajinNtIEbiPbqEtDvkC7Ydrg",
@ -575,7 +628,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -589,7 +642,7 @@ Channel(
VideoItem( VideoItem(
id: "3un2eUAr6Dg", id: "3un2eUAr6Dg",
name: "cheesy korean corn balls hit different", name: "cheesy korean corn balls hit different",
duration: None, length: Some(46),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/3un2eUAr6Dg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD4LziL6GHd1jg8btMJDIM_RhgE_A", url: "https://i.ytimg.com/vi/3un2eUAr6Dg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD4LziL6GHd1jg8btMJDIM_RhgE_A",
@ -601,7 +654,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -615,7 +668,7 @@ Channel(
VideoItem( VideoItem(
id: "rI5tWrGpDJA", id: "rI5tWrGpDJA",
name: "hawaiian tajin?!?", name: "hawaiian tajin?!?",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/rI5tWrGpDJA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAjNiKHdFSKGavBrZRDxi9WdR-gJw", url: "https://i.ytimg.com/vi/rI5tWrGpDJA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAjNiKHdFSKGavBrZRDxi9WdR-gJw",
@ -627,7 +680,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -641,7 +694,7 @@ Channel(
VideoItem( VideoItem(
id: "WQiGksTxr5g", id: "WQiGksTxr5g",
name: "Rating everything I ate at Hawaiian Supermarket 🌺🏰 pt.2", name: "Rating everything I ate at Hawaiian Supermarket 🌺🏰 pt.2",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/WQiGksTxr5g/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCvzC5xVdTEJX8xtiOqzmeKvmouIg", url: "https://i.ytimg.com/vi/WQiGksTxr5g/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCvzC5xVdTEJX8xtiOqzmeKvmouIg",
@ -653,7 +706,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -667,7 +720,7 @@ Channel(
VideoItem( VideoItem(
id: "G7aw-QOsagk", id: "G7aw-QOsagk",
name: "Grocery Shopping at Hawaiian Supermarket 🌺🏰 pt.1", name: "Grocery Shopping at Hawaiian Supermarket 🌺🏰 pt.1",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/G7aw-QOsagk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAoKEkj2lqYU07yW_DU35TNHEOq4w", url: "https://i.ytimg.com/vi/G7aw-QOsagk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAoKEkj2lqYU07yW_DU35TNHEOq4w",
@ -679,7 +732,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -693,7 +746,7 @@ Channel(
VideoItem( VideoItem(
id: "Y_F1_Yf-DKQ", id: "Y_F1_Yf-DKQ",
name: "Breakfast at Hawaiian McDonald\'s 🌺", name: "Breakfast at Hawaiian McDonald\'s 🌺",
duration: None, length: Some(61),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Y_F1_Yf-DKQ/hq720_2.jpg?sqp=-oaymwEdCJUDENAFSFXyq4qpAw8IARUAAIhCcAHAAQbQAQE=&rs=AOn4CLDTLFBwRThZUk0eugFSNxc-CKI_HQ", url: "https://i.ytimg.com/vi/Y_F1_Yf-DKQ/hq720_2.jpg?sqp=-oaymwEdCJUDENAFSFXyq4qpAw8IARUAAIhCcAHAAQbQAQE=&rs=AOn4CLDTLFBwRThZUk0eugFSNxc-CKI_HQ",
@ -705,7 +758,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -719,7 +772,7 @@ Channel(
VideoItem( VideoItem(
id: "Q_ZMcP8faw4", id: "Q_ZMcP8faw4",
name: "crab rangoon toast 🦀 🍞", name: "crab rangoon toast 🦀 🍞",
duration: None, length: Some(55),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Q_ZMcP8faw4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLATLiHTNqLoBKsEKbOckkGjXMvoHA", url: "https://i.ytimg.com/vi/Q_ZMcP8faw4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLATLiHTNqLoBKsEKbOckkGjXMvoHA",
@ -731,7 +784,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -745,7 +798,7 @@ Channel(
VideoItem( VideoItem(
id: "1aedyP3r3D0", id: "1aedyP3r3D0",
name: "my secret hot pot sauce 🧙\u{200d}♀\u{fe0f}🍃", name: "my secret hot pot sauce 🧙\u{200d}♀\u{fe0f}🍃",
duration: None, length: Some(59),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/1aedyP3r3D0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCh2MpR5k3jCS_wfX-wjtVuIcu7YQ", url: "https://i.ytimg.com/vi/1aedyP3r3D0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCh2MpR5k3jCS_wfX-wjtVuIcu7YQ",
@ -757,7 +810,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -771,7 +824,7 @@ Channel(
VideoItem( VideoItem(
id: "fkPkHZ1yyBU", id: "fkPkHZ1yyBU",
name: "the good vs the bad", name: "the good vs the bad",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/fkPkHZ1yyBU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCMngiRtLrBPppmfPnJwJ-cYMwttA", url: "https://i.ytimg.com/vi/fkPkHZ1yyBU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCMngiRtLrBPppmfPnJwJ-cYMwttA",
@ -783,7 +836,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -797,7 +850,7 @@ Channel(
VideoItem( VideoItem(
id: "NbQcySLMLmA", id: "NbQcySLMLmA",
name: "cooking with waste?!🗑\u{fe0f}", name: "cooking with waste?!🗑\u{fe0f}",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/NbQcySLMLmA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCvxPQo9eqYwjk4cxyBnrHed-tcZg", url: "https://i.ytimg.com/vi/NbQcySLMLmA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCvxPQo9eqYwjk4cxyBnrHed-tcZg",
@ -809,7 +862,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -823,7 +876,7 @@ Channel(
VideoItem( VideoItem(
id: "3w_5vzM1Pc4", id: "3w_5vzM1Pc4",
name: "Shrek burger 🍔🍀👹", name: "Shrek burger 🍔🍀👹",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/3w_5vzM1Pc4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLB64zOKgmhOt7bvQseeIbjKBICDAg", url: "https://i.ytimg.com/vi/3w_5vzM1Pc4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLB64zOKgmhOt7bvQseeIbjKBICDAg",
@ -835,7 +888,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -849,7 +902,7 @@ Channel(
VideoItem( VideoItem(
id: "girJP2r_zLg", id: "girJP2r_zLg",
name: "$$$ on food", name: "$$$ on food",
duration: None, length: Some(55),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/girJP2r_zLg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBg2hmruZvx30aiP4Jb4dhz03qOZA", url: "https://i.ytimg.com/vi/girJP2r_zLg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBg2hmruZvx30aiP4Jb4dhz03qOZA",
@ -861,7 +914,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -875,7 +928,7 @@ Channel(
VideoItem( VideoItem(
id: "zHp7sZ5OONM", id: "zHp7sZ5OONM",
name: "pumpkin spice churro?! 🎃", name: "pumpkin spice churro?! 🎃",
duration: None, length: Some(58),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/zHp7sZ5OONM/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD8ZrcI6mq91ARKnRb_vg-0Qv2raw", url: "https://i.ytimg.com/vi/zHp7sZ5OONM/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD8ZrcI6mq91ARKnRb_vg-0Qv2raw",
@ -887,7 +940,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -901,7 +954,7 @@ Channel(
VideoItem( VideoItem(
id: "iqMl3gQEZ0E", id: "iqMl3gQEZ0E",
name: "3,000,000", name: "3,000,000",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/iqMl3gQEZ0E/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBUC1sw84NlLiyTJTcfnDWFjVC75w", url: "https://i.ytimg.com/vi/iqMl3gQEZ0E/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBUC1sw84NlLiyTJTcfnDWFjVC75w",
@ -913,7 +966,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -927,7 +980,7 @@ Channel(
VideoItem( VideoItem(
id: "glyJWxp7a5g", id: "glyJWxp7a5g",
name: "being smart was my personality trait", name: "being smart was my personality trait",
duration: None, length: Some(56),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/glyJWxp7a5g/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBbrWwLndPt5ZV5x4dnqmTC_aAhig", url: "https://i.ytimg.com/vi/glyJWxp7a5g/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBbrWwLndPt5ZV5x4dnqmTC_aAhig",
@ -939,7 +992,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -953,7 +1006,7 @@ Channel(
VideoItem( VideoItem(
id: "dd1EZIkANYs", id: "dd1EZIkANYs",
name: "the horror maze", name: "the horror maze",
duration: None, length: Some(44),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/dd1EZIkANYs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBlqz2BM3K2VeLlXMPBVwXNXih6vg", url: "https://i.ytimg.com/vi/dd1EZIkANYs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBlqz2BM3K2VeLlXMPBVwXNXih6vg",
@ -965,7 +1018,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -979,7 +1032,7 @@ Channel(
VideoItem( VideoItem(
id: "enioc_stRww", id: "enioc_stRww",
name: "furikake bagels with wasabi cream cheese", name: "furikake bagels with wasabi cream cheese",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/enioc_stRww/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBz9Qo96FWssNsMhQ54DMxdYYwLfQ", url: "https://i.ytimg.com/vi/enioc_stRww/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBz9Qo96FWssNsMhQ54DMxdYYwLfQ",
@ -991,7 +1044,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1005,7 +1058,7 @@ Channel(
VideoItem( VideoItem(
id: "NUM8kCPas5w", id: "NUM8kCPas5w",
name: "simple is best", name: "simple is best",
duration: None, length: Some(49),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/NUM8kCPas5w/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC8N3YRr9A6-u6L0AtMynct4C_GzQ", url: "https://i.ytimg.com/vi/NUM8kCPas5w/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC8N3YRr9A6-u6L0AtMynct4C_GzQ",
@ -1017,7 +1070,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1031,7 +1084,7 @@ Channel(
VideoItem( VideoItem(
id: "1djkcsFnlYE", id: "1djkcsFnlYE",
name: "edible history lesson!", name: "edible history lesson!",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/1djkcsFnlYE/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBHn_6yOrnRXH_zbxVaAuKzSulcew", url: "https://i.ytimg.com/vi/1djkcsFnlYE/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBHn_6yOrnRXH_zbxVaAuKzSulcew",
@ -1043,7 +1096,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1057,7 +1110,7 @@ Channel(
VideoItem( VideoItem(
id: "cIYrJtAoftI", id: "cIYrJtAoftI",
name: "and I\'m feeling good", name: "and I\'m feeling good",
duration: None, length: Some(53),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/cIYrJtAoftI/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC4q0VcbBZroejhAztDkdlk7Ww5Og", url: "https://i.ytimg.com/vi/cIYrJtAoftI/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC4q0VcbBZroejhAztDkdlk7Ww5Og",
@ -1069,7 +1122,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1083,7 +1136,7 @@ Channel(
VideoItem( VideoItem(
id: "cCrH8Er5tf4", id: "cCrH8Er5tf4",
name: "Rating Korean Convenience Store Milk Flavors 🥛🍼", name: "Rating Korean Convenience Store Milk Flavors 🥛🍼",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/cCrH8Er5tf4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBwc2ikrGH_gZfcyqTnZDfHjt5LuA", url: "https://i.ytimg.com/vi/cCrH8Er5tf4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBwc2ikrGH_gZfcyqTnZDfHjt5LuA",
@ -1095,7 +1148,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1109,7 +1162,7 @@ Channel(
VideoItem( VideoItem(
id: "tav5wsH7pzU", id: "tav5wsH7pzU",
name: "online dating?", name: "online dating?",
duration: None, length: Some(58),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/tav5wsH7pzU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCheup7XAM_O1UAMEO5Iqup4-lGRQ", url: "https://i.ytimg.com/vi/tav5wsH7pzU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCheup7XAM_O1UAMEO5Iqup4-lGRQ",
@ -1121,7 +1174,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1135,7 +1188,7 @@ Channel(
VideoItem( VideoItem(
id: "5Vd4_GXjF7o", id: "5Vd4_GXjF7o",
name: "Creating thumbnails has never been easier with Adobe Express", name: "Creating thumbnails has never been easier with Adobe Express",
duration: None, length: Some(26),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/5Vd4_GXjF7o/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCbYkH7INYGHW0IcO3DKip5iD2PCA", url: "https://i.ytimg.com/vi/5Vd4_GXjF7o/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCbYkH7INYGHW0IcO3DKip5iD2PCA",
@ -1147,7 +1200,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1161,7 +1214,7 @@ Channel(
VideoItem( VideoItem(
id: "-FN1sEI8HkU", id: "-FN1sEI8HkU",
name: "my favorite color is green", name: "my favorite color is green",
duration: None, length: Some(45),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/-FN1sEI8HkU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCLWKPrR-VCdsXagJ1MIyah7dDdDQ", url: "https://i.ytimg.com/vi/-FN1sEI8HkU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCLWKPrR-VCdsXagJ1MIyah7dDdDQ",
@ -1173,7 +1226,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1187,7 +1240,7 @@ Channel(
VideoItem( VideoItem(
id: "viT-dcl2DGE", id: "viT-dcl2DGE",
name: "frodo baggins?", name: "frodo baggins?",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/viT-dcl2DGE/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDb0oYC_3V79CSR0j-4sR4CuNQekQ", url: "https://i.ytimg.com/vi/viT-dcl2DGE/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDb0oYC_3V79CSR0j-4sR4CuNQekQ",
@ -1199,7 +1252,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1213,7 +1266,7 @@ Channel(
VideoItem( VideoItem(
id: "N5AKQflK1TU", id: "N5AKQflK1TU",
name: "When you impulse buy...", name: "When you impulse buy...",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/N5AKQflK1TU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDwfPTcuQHyziYsmTrSkg9xi1jnag", url: "https://i.ytimg.com/vi/N5AKQflK1TU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDwfPTcuQHyziYsmTrSkg9xi1jnag",
@ -1225,7 +1278,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1239,7 +1292,7 @@ Channel(
VideoItem( VideoItem(
id: "OzIFALQ_YtA", id: "OzIFALQ_YtA",
name: "taste testing gam!", name: "taste testing gam!",
duration: None, length: Some(60),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/OzIFALQ_YtA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBMcyG6Fu4rrXk-JQL5tx0hzSAxlw", url: "https://i.ytimg.com/vi/OzIFALQ_YtA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBMcyG6Fu4rrXk-JQL5tx0hzSAxlw",
@ -1251,7 +1304,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1265,7 +1318,7 @@ Channel(
VideoItem( VideoItem(
id: "dAcJILbc_0Q", id: "dAcJILbc_0Q",
name: "How to: Korean rice wine 🍶 (makgeolli)", name: "How to: Korean rice wine 🍶 (makgeolli)",
duration: None, length: Some(59),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/dAcJILbc_0Q/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAXbHym4PFTTO25GCI4n1tjSaQVCw", url: "https://i.ytimg.com/vi/dAcJILbc_0Q/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAXbHym4PFTTO25GCI4n1tjSaQVCw",
@ -1277,7 +1330,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1291,7 +1344,7 @@ Channel(
VideoItem( VideoItem(
id: "GvutfmW26JQ", id: "GvutfmW26JQ",
name: "👹stay sour 🍋", name: "👹stay sour 🍋",
duration: None, length: Some(52),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/GvutfmW26JQ/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBgCJ06W3wOend0UgkuBKoHOg0eig", url: "https://i.ytimg.com/vi/GvutfmW26JQ/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBgCJ06W3wOend0UgkuBKoHOg0eig",
@ -1303,7 +1356,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(3360000), subscriber_count: Some(3360000),
)), )),
publish_date: "[date]", publish_date: "[date]",

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
handle: None,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/dm5Aq93xvVJz0NoVO88ieBkDXmuShCujGPlZ7qETMEPTrXvPUCFI3-BB6Xs_P-r6Uk3mnBy9zA=s48-c-k-c0x00ffffff-no-rj", url: "https://yt3.ggpht.com/dm5Aq93xvVJz0NoVO88ieBkDXmuShCujGPlZ7qETMEPTrXvPUCFI3-BB6Xs_P-r6Uk3mnBy9zA=s48-c-k-c0x00ffffff-no-rj",
@ -25,9 +23,10 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: verified, verification: Verified,
description: "Hi, Im Tina, aka Doobydobap!\n\nFood is the medium I use to tell stories and connect with people who share the same passion as I do. Whether its because youre hungry at midnight or trying to learn how to cook, I hope you enjoy watching my content and recipes. Don\'t yuck my yum!\n\nwww.doobydobap.com\n", description: "Hi, Im Tina, aka Doobydobap!\n\nFood is the medium I use to tell stories and connect with people who share the same passion as I do. Whether its because youre hungry at midnight or trying to learn how to cook, I hope you enjoy watching my content and recipes. Don\'t yuck my yum!\n\nwww.doobydobap.com\n",
tags: [], tags: [],
vanity_url: Some("https://www.youtube.com/c/Doobydobap"),
banner: [ banner: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
@ -60,6 +59,60 @@ Channel(
height: 424, height: 424,
), ),
], ],
mobile_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: true, has_shorts: true,
has_live: false, has_live: false,
visitor_data: Some("CgtQdE9zVVR3NVBDbyjz0ZKaBg%3D%3D"), visitor_data: Some("CgtQdE9zVVR3NVBDbyjz0ZKaBg%3D%3D"),
@ -69,7 +122,7 @@ Channel(
VideoItem( VideoItem(
id: "EIcmfSzeaKk", id: "EIcmfSzeaKk",
name: "our new normal", name: "our new normal",
duration: Some(1106), length: Some(1106),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/EIcmfSzeaKk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAsYqYyFrXWHOkwiw0oqls2tGrKQg", url: "https://i.ytimg.com/vi/EIcmfSzeaKk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAsYqYyFrXWHOkwiw0oqls2tGrKQg",
@ -96,7 +149,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -110,7 +163,7 @@ Channel(
VideoItem( VideoItem(
id: "9NuhKCv3crg", id: "9NuhKCv3crg",
name: "the end.", name: "the end.",
duration: Some(982), length: Some(982),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/9NuhKCv3crg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDB0KHjIok8E-gjwidP56UeDJy7Bg", url: "https://i.ytimg.com/vi/9NuhKCv3crg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDB0KHjIok8E-gjwidP56UeDJy7Bg",
@ -137,7 +190,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -151,7 +204,7 @@ Channel(
VideoItem( VideoItem(
id: "38Gd6TdmNVs", id: "38Gd6TdmNVs",
name: "KOREAN BARBECUE l doob gourmand ep.3", name: "KOREAN BARBECUE l doob gourmand ep.3",
duration: Some(525), length: Some(525),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/38Gd6TdmNVs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBRo5niO28TGS9JNluTU9wCLCGBQA", url: "https://i.ytimg.com/vi/38Gd6TdmNVs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBRo5niO28TGS9JNluTU9wCLCGBQA",
@ -178,7 +231,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -192,7 +245,7 @@ Channel(
VideoItem( VideoItem(
id: "l9TiwunjzgA", id: "l9TiwunjzgA",
name: "long distance", name: "long distance",
duration: Some(1043), length: Some(1043),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/l9TiwunjzgA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDjM6SZ7ScyfFRr13QdVmIvWEWWrQ", url: "https://i.ytimg.com/vi/l9TiwunjzgA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDjM6SZ7ScyfFRr13QdVmIvWEWWrQ",
@ -219,7 +272,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -233,7 +286,7 @@ Channel(
VideoItem( VideoItem(
id: "pRVSdUxdsVw", id: "pRVSdUxdsVw",
name: "Repairing...", name: "Repairing...",
duration: Some(965), length: Some(965),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/pRVSdUxdsVw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAQWneuYcJcccgooBfa3WI4LdYF3w", url: "https://i.ytimg.com/vi/pRVSdUxdsVw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAQWneuYcJcccgooBfa3WI4LdYF3w",
@ -260,7 +313,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -274,7 +327,7 @@ Channel(
VideoItem( VideoItem(
id: "2FJVhdOO0F0", id: "2FJVhdOO0F0",
name: "a health scare", name: "a health scare",
duration: Some(1238), length: Some(1238),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/2FJVhdOO0F0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA5ambaz-euRsB9VG5ANaYFUUSEbg", url: "https://i.ytimg.com/vi/2FJVhdOO0F0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA5ambaz-euRsB9VG5ANaYFUUSEbg",
@ -301,7 +354,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -315,7 +368,7 @@ Channel(
VideoItem( VideoItem(
id: "CutR_1SDDzY", id: "CutR_1SDDzY",
name: "feels good to be back", name: "feels good to be back",
duration: Some(1159), length: Some(1159),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/CutR_1SDDzY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAt413Uk4xhHjYwpLI5-DXuOsFouA", url: "https://i.ytimg.com/vi/CutR_1SDDzY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAt413Uk4xhHjYwpLI5-DXuOsFouA",
@ -342,7 +395,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -356,7 +409,7 @@ Channel(
VideoItem( VideoItem(
id: "KUz7oArksR4", id: "KUz7oArksR4",
name: "running away", name: "running away",
duration: Some(1023), length: Some(1023),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/KUz7oArksR4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD1NwuIgJuJy2oPAiHqMre6rbcuPA", url: "https://i.ytimg.com/vi/KUz7oArksR4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD1NwuIgJuJy2oPAiHqMre6rbcuPA",
@ -383,7 +436,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -397,7 +450,7 @@ Channel(
VideoItem( VideoItem(
id: "sPb2gyN-hnE", id: "sPb2gyN-hnE",
name: "worth fighting for", name: "worth fighting for",
duration: Some(1232), length: Some(1232),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/sPb2gyN-hnE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBidXnS47SJMkvOlqt2DgzHxr6wKQ", url: "https://i.ytimg.com/vi/sPb2gyN-hnE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBidXnS47SJMkvOlqt2DgzHxr6wKQ",
@ -424,7 +477,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -438,7 +491,7 @@ Channel(
VideoItem( VideoItem(
id: "PXsK9-CFoH4", id: "PXsK9-CFoH4",
name: "waiting...", name: "waiting...",
duration: Some(1455), length: Some(1455),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/PXsK9-CFoH4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBJ-57qZ-dOIsdFy5H8WT9UsS2W9w", url: "https://i.ytimg.com/vi/PXsK9-CFoH4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBJ-57qZ-dOIsdFy5H8WT9UsS2W9w",
@ -465,7 +518,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -479,7 +532,7 @@ Channel(
VideoItem( VideoItem(
id: "r2ye6zW0nbM", id: "r2ye6zW0nbM",
name: "a wedding", name: "a wedding",
duration: Some(1207), length: Some(1207),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/r2ye6zW0nbM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB3L2DVtMtxaPaFjVPcNnjDHE5Wvw", url: "https://i.ytimg.com/vi/r2ye6zW0nbM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB3L2DVtMtxaPaFjVPcNnjDHE5Wvw",
@ -506,7 +559,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -520,7 +573,7 @@ Channel(
VideoItem( VideoItem(
id: "rriwHj8U664", id: "rriwHj8U664",
name: "my seoul apartment tour", name: "my seoul apartment tour",
duration: Some(721), length: Some(721),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/rriwHj8U664/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy6zauLaf2KLJ6R41q0CPM8298PA", url: "https://i.ytimg.com/vi/rriwHj8U664/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy6zauLaf2KLJ6R41q0CPM8298PA",
@ -547,7 +600,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -561,7 +614,7 @@ Channel(
VideoItem( VideoItem(
id: "FKJtrUeol3o", id: "FKJtrUeol3o",
name: "with quantity comes quality", name: "with quantity comes quality",
duration: Some(1140), length: Some(1140),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/FKJtrUeol3o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD7U0hZPrEiHZcTVcicymOllR05qw", url: "https://i.ytimg.com/vi/FKJtrUeol3o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD7U0hZPrEiHZcTVcicymOllR05qw",
@ -588,7 +641,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -602,7 +655,7 @@ Channel(
VideoItem( VideoItem(
id: "zYHB38UlzE0", id: "zYHB38UlzE0",
name: "Q&A l relationships, burnout, privilege, college advice, living alone, and life after youtube?", name: "Q&A l relationships, burnout, privilege, college advice, living alone, and life after youtube?",
duration: Some(775), length: Some(775),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/zYHB38UlzE0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDAO5etokCiF7cvyR-7kobN9RhTLA", url: "https://i.ytimg.com/vi/zYHB38UlzE0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDAO5etokCiF7cvyR-7kobN9RhTLA",
@ -629,7 +682,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -643,7 +696,7 @@ Channel(
VideoItem( VideoItem(
id: "hGbQ2WM9nOo", id: "hGbQ2WM9nOo",
name: "Why does everything bad for you taste good ㅣ CHILI OIL RAMEN", name: "Why does everything bad for you taste good ㅣ CHILI OIL RAMEN",
duration: Some(428), length: Some(428),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/hGbQ2WM9nOo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD_bMKoJhW-ifemEiqSBj-6dvEnUg", url: "https://i.ytimg.com/vi/hGbQ2WM9nOo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD_bMKoJhW-ifemEiqSBj-6dvEnUg",
@ -670,7 +723,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -684,7 +737,7 @@ Channel(
VideoItem( VideoItem(
id: "PxGmP4v_A38", id: "PxGmP4v_A38",
name: "Alone and Thriving l late night korean convenience store, muji kitchenware haul, spring cleaning!", name: "Alone and Thriving l late night korean convenience store, muji kitchenware haul, spring cleaning!",
duration: Some(1437), length: Some(1437),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/PxGmP4v_A38/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLArZRyFU5e71-vMdGZzuxCCroEkww", url: "https://i.ytimg.com/vi/PxGmP4v_A38/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLArZRyFU5e71-vMdGZzuxCCroEkww",
@ -711,7 +764,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -725,7 +778,7 @@ Channel(
VideoItem( VideoItem(
id: "8t-WyYcpEDE", id: "8t-WyYcpEDE",
name: "What I hate most", name: "What I hate most",
duration: Some(61), length: Some(61),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/8t-WyYcpEDE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsJHHXMP4fUEFqn-LExXU5yPyZ-Q", url: "https://i.ytimg.com/vi/8t-WyYcpEDE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsJHHXMP4fUEFqn-LExXU5yPyZ-Q",
@ -752,7 +805,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -766,7 +819,7 @@ Channel(
VideoItem( VideoItem(
id: "RroYpLxxNjY", id: "RroYpLxxNjY",
name: "I\'m Back. ㅣ cooking korean food, eating alone, working out, and 2M!", name: "I\'m Back. ㅣ cooking korean food, eating alone, working out, and 2M!",
duration: Some(1313), length: Some(1313),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/RroYpLxxNjY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYJ_dcqUt2kR-4jOAUu8O0Ja9SLA", url: "https://i.ytimg.com/vi/RroYpLxxNjY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYJ_dcqUt2kR-4jOAUu8O0Ja9SLA",
@ -793,7 +846,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -807,7 +860,7 @@ Channel(
VideoItem( VideoItem(
id: "l47QuudsZ34", id: "l47QuudsZ34",
name: "We ate our way through Florence (ft. mamadooby)", name: "We ate our way through Florence (ft. mamadooby)",
duration: Some(1109), length: Some(1109),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/l47QuudsZ34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB0Vwc7DhN_hFXSRuDAiivLnGGc2A", url: "https://i.ytimg.com/vi/l47QuudsZ34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB0Vwc7DhN_hFXSRuDAiivLnGGc2A",
@ -834,7 +887,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -848,7 +901,7 @@ Channel(
VideoItem( VideoItem(
id: "1VW7iXRIrc8", id: "1VW7iXRIrc8",
name: "Alone, in the City of Love", name: "Alone, in the City of Love",
duration: Some(1875), length: Some(1875),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/1VW7iXRIrc8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBbNxPLmGzJlvJ-3o5Dz9I5LOGu1A", url: "https://i.ytimg.com/vi/1VW7iXRIrc8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBbNxPLmGzJlvJ-3o5Dz9I5LOGu1A",
@ -875,7 +928,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -889,7 +942,7 @@ Channel(
VideoItem( VideoItem(
id: "6c58-749p6Y", id: "6c58-749p6Y",
name: "Old Friends & New", name: "Old Friends & New",
duration: Some(774), length: Some(774),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/6c58-749p6Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClRrTlOF_Q3feHLoM0T5_DFygbIw", url: "https://i.ytimg.com/vi/6c58-749p6Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClRrTlOF_Q3feHLoM0T5_DFygbIw",
@ -916,7 +969,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -930,7 +983,7 @@ Channel(
VideoItem( VideoItem(
id: "Q2G53LuEUaU", id: "Q2G53LuEUaU",
name: "Where we stand", name: "Where we stand",
duration: Some(858), length: Some(858),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Q2G53LuEUaU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC1ppNrqK-xlQ6Sxnn62dp8QXoJBQ", url: "https://i.ytimg.com/vi/Q2G53LuEUaU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC1ppNrqK-xlQ6Sxnn62dp8QXoJBQ",
@ -957,7 +1010,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -971,7 +1024,7 @@ Channel(
VideoItem( VideoItem(
id: "8rAOeowNQrI", id: "8rAOeowNQrI",
name: "That\'s so last year", name: "That\'s so last year",
duration: Some(1286), length: Some(1286),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/8rAOeowNQrI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCSBW_fD0pttfFh4Yc_Kx1UIZHzfg", url: "https://i.ytimg.com/vi/8rAOeowNQrI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCSBW_fD0pttfFh4Yc_Kx1UIZHzfg",
@ -998,7 +1051,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1012,7 +1065,7 @@ Channel(
VideoItem( VideoItem(
id: "0RGIdIKkbSI", id: "0RGIdIKkbSI",
name: "The Muffin Man", name: "The Muffin Man",
duration: Some(1052), length: Some(1052),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/0RGIdIKkbSI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDo42DBFMfLKVHtXETG5yuU20FVMw", url: "https://i.ytimg.com/vi/0RGIdIKkbSI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDo42DBFMfLKVHtXETG5yuU20FVMw",
@ -1039,7 +1092,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1053,7 +1106,7 @@ Channel(
VideoItem( VideoItem(
id: "NudTbo2CJMY", id: "NudTbo2CJMY",
name: "Flying to London", name: "Flying to London",
duration: Some(1078), length: Some(1078),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/NudTbo2CJMY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEdvWWmhSaDTTx7b2kJUauMFnQJQ", url: "https://i.ytimg.com/vi/NudTbo2CJMY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEdvWWmhSaDTTx7b2kJUauMFnQJQ",
@ -1080,7 +1133,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1094,7 +1147,7 @@ Channel(
VideoItem( VideoItem(
id: "8mJk1ncGZig", id: "8mJk1ncGZig",
name: "(not so) Teenage Angst", name: "(not so) Teenage Angst",
duration: Some(1376), length: Some(1376),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/8mJk1ncGZig/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB1kTcJ6oRyNfaGJbvl6V5UxRhagg", url: "https://i.ytimg.com/vi/8mJk1ncGZig/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB1kTcJ6oRyNfaGJbvl6V5UxRhagg",
@ -1121,7 +1174,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1135,7 +1188,7 @@ Channel(
VideoItem( VideoItem(
id: "qvgCi2WpbfE", id: "qvgCi2WpbfE",
name: "can\'t smell :s", name: "can\'t smell :s",
duration: Some(875), length: Some(875),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/qvgCi2WpbfE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBdSLKkLlOTxprZAH9BajRpHiujrw", url: "https://i.ytimg.com/vi/qvgCi2WpbfE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBdSLKkLlOTxprZAH9BajRpHiujrw",
@ -1162,7 +1215,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1176,7 +1229,7 @@ Channel(
VideoItem( VideoItem(
id: "Sm4Yqtqr9f8", id: "Sm4Yqtqr9f8",
name: "I have covid", name: "I have covid",
duration: Some(814), length: Some(814),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Sm4Yqtqr9f8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDAAWaXioP-Xz_cwkE3APR_5fpkqw", url: "https://i.ytimg.com/vi/Sm4Yqtqr9f8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDAAWaXioP-Xz_cwkE3APR_5fpkqw",
@ -1203,7 +1256,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1217,7 +1270,7 @@ Channel(
VideoItem( VideoItem(
id: "ZRtf4ksF3qs", id: "ZRtf4ksF3qs",
name: "Everything I ate in Busan & make up tutorial??", name: "Everything I ate in Busan & make up tutorial??",
duration: Some(1026), length: Some(1026),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/ZRtf4ksF3qs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBnRStN9mU3cu7vDQIkUcO3WiyVZw", url: "https://i.ytimg.com/vi/ZRtf4ksF3qs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBnRStN9mU3cu7vDQIkUcO3WiyVZw",
@ -1244,7 +1297,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1258,7 +1311,7 @@ Channel(
VideoItem( VideoItem(
id: "oG4Wth1oVBQ", id: "oG4Wth1oVBQ",
name: "On the other side", name: "On the other side",
duration: Some(1592), length: Some(1592),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/oG4Wth1oVBQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDO45Wm2zkuD6ZukxaoxfgGkpuZHg", url: "https://i.ytimg.com/vi/oG4Wth1oVBQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDO45Wm2zkuD6ZukxaoxfgGkpuZHg",
@ -1285,7 +1338,7 @@ Channel(
id: "UCh8gHdtzO2tXd593_bjErWg", id: "UCh8gHdtzO2tXd593_bjErWg",
name: "Doobydobap", name: "Doobydobap",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2930000), subscriber_count: Some(2930000),
)), )),
publish_date: "[date]", publish_date: "[date]",

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
handle: None,
subscriber_count: Some(883000), subscriber_count: Some(883000),
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
@ -25,7 +23,7 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: verified, verification: Verified,
description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA",
tags: [ tags: [
"electronics", "electronics",
@ -57,6 +55,7 @@ Channel(
"dumpster diving", "dumpster diving",
"debunking", "debunking",
], ],
vanity_url: Some("https://www.youtube.com/c/EevblogDave"),
banner: [ banner: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
@ -89,6 +88,60 @@ Channel(
height: 424, height: 424,
), ),
], ],
mobile_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: false, has_shorts: false,
has_live: true, has_live: true,
visitor_data: Some("Cgs4ZFVmMzVlU1dxbyiBqpeaBg%3D%3D"), visitor_data: Some("Cgs4ZFVmMzVlU1dxbyiBqpeaBg%3D%3D"),
@ -98,7 +151,7 @@ Channel(
VideoItem( VideoItem(
id: "4EcQYK_no5M", id: "4EcQYK_no5M",
name: "EEVblog 1506 - History of Electricity with Kathy Loves Physics", name: "EEVblog 1506 - History of Electricity with Kathy Loves Physics",
duration: Some(6143), length: Some(6143),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/4EcQYK_no5M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB9dr9RxHmrRUim7aDSz_mPNrfSKA", url: "https://i.ytimg.com/vi/4EcQYK_no5M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB9dr9RxHmrRUim7aDSz_mPNrfSKA",
@ -125,7 +178,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -139,7 +192,7 @@ Channel(
VideoItem( VideoItem(
id: "zEzjVUzNAFA", id: "zEzjVUzNAFA",
name: "EEVblog 1505 - 120W Home Phantom Power? Audit Time!", name: "EEVblog 1505 - 120W Home Phantom Power? Audit Time!",
duration: Some(1464), length: Some(1464),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/zEzjVUzNAFA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnSdLCdtqGA1HYCFv4_MeTHWdVpw", url: "https://i.ytimg.com/vi/zEzjVUzNAFA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnSdLCdtqGA1HYCFv4_MeTHWdVpw",
@ -166,7 +219,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -180,7 +233,7 @@ Channel(
VideoItem( VideoItem(
id: "YIbQ3nudCA0", id: "YIbQ3nudCA0",
name: "EEVblog 1504 - The COOL thing you MISSED at Tesla AI Day 2022", name: "EEVblog 1504 - The COOL thing you MISSED at Tesla AI Day 2022",
duration: Some(1021), length: Some(1021),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/YIbQ3nudCA0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDesF0deVLo0ouizZ8ZF_lXolOdrw", url: "https://i.ytimg.com/vi/YIbQ3nudCA0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDesF0deVLo0ouizZ8ZF_lXolOdrw",
@ -207,7 +260,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -221,7 +274,7 @@ Channel(
VideoItem( VideoItem(
id: "W1Jl0rMRGSg", id: "W1Jl0rMRGSg",
name: "EEVblog 1503 - Rigol HDO4000 12bit Oscilloscope TEARDOWN", name: "EEVblog 1503 - Rigol HDO4000 12bit Oscilloscope TEARDOWN",
duration: Some(1798), length: Some(1798),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/W1Jl0rMRGSg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBKFi3YtWo1ii8h8FdQN6CkYgzX2A", url: "https://i.ytimg.com/vi/W1Jl0rMRGSg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBKFi3YtWo1ii8h8FdQN6CkYgzX2A",
@ -248,7 +301,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -262,7 +315,7 @@ Channel(
VideoItem( VideoItem(
id: "YFKu_emNzpk", id: "YFKu_emNzpk",
name: "EEVblog 1502 - Is Home Battery Storage Financially Viable?", name: "EEVblog 1502 - Is Home Battery Storage Financially Viable?",
duration: Some(1199), length: Some(1199),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/YFKu_emNzpk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLACI3L7nXsK3ZUFD8yK0VAWd32-Uw", url: "https://i.ytimg.com/vi/YFKu_emNzpk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLACI3L7nXsK3ZUFD8yK0VAWd32-Uw",
@ -289,7 +342,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -303,7 +356,7 @@ Channel(
VideoItem( VideoItem(
id: "gremHHvqYTE", id: "gremHHvqYTE",
name: "EEVblog 1501 - Rigol HDO4000 Low Noise 12bit Oscilloscope Unboxing & First Impression", name: "EEVblog 1501 - Rigol HDO4000 Low Noise 12bit Oscilloscope Unboxing & First Impression",
duration: Some(1794), length: Some(1794),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/gremHHvqYTE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBcwR0YIwLjfFam9HkKdkTkqx_gHw", url: "https://i.ytimg.com/vi/gremHHvqYTE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBcwR0YIwLjfFam9HkKdkTkqx_gHw",
@ -330,7 +383,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -344,7 +397,7 @@ Channel(
VideoItem( VideoItem(
id: "WHO8NBfpaO0", id: "WHO8NBfpaO0",
name: "eevBLAB 102 - Last Mile Autonomous Robot Deliveries WILL FAIL", name: "eevBLAB 102 - Last Mile Autonomous Robot Deliveries WILL FAIL",
duration: Some(742), length: Some(742),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/WHO8NBfpaO0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQPKMF3Aeo9CydEWz9pQWkn1Lu7Q", url: "https://i.ytimg.com/vi/WHO8NBfpaO0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQPKMF3Aeo9CydEWz9pQWkn1Lu7Q",
@ -371,7 +424,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -385,7 +438,7 @@ Channel(
VideoItem( VideoItem(
id: "W1Q8CxL95_Y", id: "W1Q8CxL95_Y",
name: "EEVblog 1500 - Automatic Transfer Switch REVERSE ENGINEERED", name: "EEVblog 1500 - Automatic Transfer Switch REVERSE ENGINEERED",
duration: Some(1770), length: Some(1770),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/W1Q8CxL95_Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBIxuct8vahJHOJTLfbOnsMOXnjvw", url: "https://i.ytimg.com/vi/W1Q8CxL95_Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBIxuct8vahJHOJTLfbOnsMOXnjvw",
@ -412,7 +465,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -426,7 +479,7 @@ Channel(
VideoItem( VideoItem(
id: "lagxSrPeoYg", id: "lagxSrPeoYg",
name: "EEVblog 1499 - EcoFlow Delta Pro 3.6kWh Portable Battery TEARDOWN!", name: "EEVblog 1499 - EcoFlow Delta Pro 3.6kWh Portable Battery TEARDOWN!",
duration: Some(2334), length: Some(2334),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/lagxSrPeoYg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAbAX2gdAF66O7BUCaOVg2vQOsS2Q", url: "https://i.ytimg.com/vi/lagxSrPeoYg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAbAX2gdAF66O7BUCaOVg2vQOsS2Q",
@ -453,7 +506,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -467,7 +520,7 @@ Channel(
VideoItem( VideoItem(
id: "qTctWW9_FmE", id: "qTctWW9_FmE",
name: "EEVblog 1498 - TransPod Fluxjet Hyperloop $550M Boondoggle!", name: "EEVblog 1498 - TransPod Fluxjet Hyperloop $550M Boondoggle!",
duration: Some(2399), length: Some(2399),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/qTctWW9_FmE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCbnEQaGGI5zD9lCJ8kMmciezX2kA", url: "https://i.ytimg.com/vi/qTctWW9_FmE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCbnEQaGGI5zD9lCJ8kMmciezX2kA",
@ -494,7 +547,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -508,7 +561,7 @@ Channel(
VideoItem( VideoItem(
id: "3t9G80wk0pk", id: "3t9G80wk0pk",
name: "eevBLAB 101 - Why Are Tektronix Oscilloscopes So Expensive?", name: "eevBLAB 101 - Why Are Tektronix Oscilloscopes So Expensive?",
duration: Some(1423), length: Some(1423),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/3t9G80wk0pk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnsVu-VQplpRpc1ZW-yk2byyZjZA", url: "https://i.ytimg.com/vi/3t9G80wk0pk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnsVu-VQplpRpc1ZW-yk2byyZjZA",
@ -535,7 +588,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -549,7 +602,7 @@ Channel(
VideoItem( VideoItem(
id: "7dze5CnZnmk", id: "7dze5CnZnmk",
name: "EEVblog 1497 - RIP Fluke. Thanks Energizer. NOT.", name: "EEVblog 1497 - RIP Fluke. Thanks Energizer. NOT.",
duration: Some(1168), length: Some(1168),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/7dze5CnZnmk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg430MYAmoycM4lbv_57S_d3kZRA", url: "https://i.ytimg.com/vi/7dze5CnZnmk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg430MYAmoycM4lbv_57S_d3kZRA",
@ -576,7 +629,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -590,7 +643,7 @@ Channel(
VideoItem( VideoItem(
id: "6XnrZpPYgBg", id: "6XnrZpPYgBg",
name: "EEVblog 1496 - Winning Mailbag", name: "EEVblog 1496 - Winning Mailbag",
duration: Some(3139), length: Some(3139),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/6XnrZpPYgBg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCrBgky13jB1p9xzKbmoUpJ4g0SNQ", url: "https://i.ytimg.com/vi/6XnrZpPYgBg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCrBgky13jB1p9xzKbmoUpJ4g0SNQ",
@ -617,7 +670,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -631,7 +684,7 @@ Channel(
VideoItem( VideoItem(
id: "Psp3ltpFvws", id: "Psp3ltpFvws",
name: "eevBLAB 100 - Reuters Attacks Odysee - LOL", name: "eevBLAB 100 - Reuters Attacks Odysee - LOL",
duration: Some(855), length: Some(855),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Psp3ltpFvws/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCu8Nu_NmDw5vBHgb7e8JdJR1Dr1Q", url: "https://i.ytimg.com/vi/Psp3ltpFvws/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCu8Nu_NmDw5vBHgb7e8JdJR1Dr1Q",
@ -658,7 +711,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -672,7 +725,7 @@ Channel(
VideoItem( VideoItem(
id: "taVYTYz5vLE", id: "taVYTYz5vLE",
name: "EEVblog 1495 - Quaze Wireless Power (AGAIN!) but for GAMING!", name: "EEVblog 1495 - Quaze Wireless Power (AGAIN!) but for GAMING!",
duration: Some(2592), length: Some(2592),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/taVYTYz5vLE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAMHngmN8TjWZz327vUD7zjjblYBw", url: "https://i.ytimg.com/vi/taVYTYz5vLE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAMHngmN8TjWZz327vUD7zjjblYBw",
@ -699,7 +752,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -713,7 +766,7 @@ Channel(
VideoItem( VideoItem(
id: "Y6cZrieFw-k", id: "Y6cZrieFw-k",
name: "EEVblog 1494 - FIVE Ways to Open a CHEAP SAFE!", name: "EEVblog 1494 - FIVE Ways to Open a CHEAP SAFE!",
duration: Some(1194), length: Some(1194),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Y6cZrieFw-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsdoJwcvSFZU4e9cwDFbZj3W21Pw", url: "https://i.ytimg.com/vi/Y6cZrieFw-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsdoJwcvSFZU4e9cwDFbZj3W21Pw",
@ -740,7 +793,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -754,7 +807,7 @@ Channel(
VideoItem( VideoItem(
id: "Kr2XyhpUdUI", id: "Kr2XyhpUdUI",
name: "EEVblog 1493 - MacGyver Project - Part 2", name: "EEVblog 1493 - MacGyver Project - Part 2",
duration: Some(1785), length: Some(1785),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Kr2XyhpUdUI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdL3brjOzbABRuyz-yolawtGRsbw", url: "https://i.ytimg.com/vi/Kr2XyhpUdUI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdL3brjOzbABRuyz-yolawtGRsbw",
@ -781,7 +834,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -795,7 +848,7 @@ Channel(
VideoItem( VideoItem(
id: "rxGafdgkal8", id: "rxGafdgkal8",
name: "EEVblog 1492 - $5 Oscilloscope Repaired! + Oz GIVEAWAY", name: "EEVblog 1492 - $5 Oscilloscope Repaired! + Oz GIVEAWAY",
duration: Some(1163), length: Some(1163),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/rxGafdgkal8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD-p_t0q_Q2oTGyJuFCQJ5z6VPPMQ", url: "https://i.ytimg.com/vi/rxGafdgkal8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD-p_t0q_Q2oTGyJuFCQJ5z6VPPMQ",
@ -822,7 +875,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -836,7 +889,7 @@ Channel(
VideoItem( VideoItem(
id: "4yosozyeIP4", id: "4yosozyeIP4",
name: "EEVblog 1491 - The MacGyver Project - Part 1", name: "EEVblog 1491 - The MacGyver Project - Part 1",
duration: Some(1706), length: Some(1706),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/4yosozyeIP4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDRNAWkPQfuQirfiOdowD1iQlWrWg", url: "https://i.ytimg.com/vi/4yosozyeIP4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDRNAWkPQfuQirfiOdowD1iQlWrWg",
@ -863,7 +916,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -877,7 +930,7 @@ Channel(
VideoItem( VideoItem(
id: "06JtC2DC_dQ", id: "06JtC2DC_dQ",
name: "EEVblog 1490 - Insane Jaycar Dumpster Sale! 2022", name: "EEVblog 1490 - Insane Jaycar Dumpster Sale! 2022",
duration: Some(1700), length: Some(1700),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/06JtC2DC_dQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDVIvEssIKji_8dyBYGYbpIqen7vQ", url: "https://i.ytimg.com/vi/06JtC2DC_dQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDVIvEssIKji_8dyBYGYbpIqen7vQ",
@ -904,7 +957,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -918,7 +971,7 @@ Channel(
VideoItem( VideoItem(
id: "piquT76w9TI", id: "piquT76w9TI",
name: "EEVblog 1489 - Mystery Teardown!", name: "EEVblog 1489 - Mystery Teardown!",
duration: Some(1466), length: Some(1466),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/piquT76w9TI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCTzIcGeRDwUyINtik50EQCOTxwiA", url: "https://i.ytimg.com/vi/piquT76w9TI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCTzIcGeRDwUyINtik50EQCOTxwiA",
@ -945,7 +998,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -959,7 +1012,7 @@ Channel(
VideoItem( VideoItem(
id: "pKuUKT-zU-g", id: "pKuUKT-zU-g",
name: "EEVblog 1488 - Tilt Five Augmented Reality AR Glasses - First Reaction!", name: "EEVblog 1488 - Tilt Five Augmented Reality AR Glasses - First Reaction!",
duration: Some(2152), length: Some(2152),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/pKuUKT-zU-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCB6Rh4miI20yPy2kJaxul_wA3Now", url: "https://i.ytimg.com/vi/pKuUKT-zU-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCB6Rh4miI20yPy2kJaxul_wA3Now",
@ -986,7 +1039,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1000,7 +1053,7 @@ Channel(
VideoItem( VideoItem(
id: "_R4wQQNSO6k", id: "_R4wQQNSO6k",
name: "EEVblog 1487 - Do Solar Micro Inverters Take Power at Night?", name: "EEVblog 1487 - Do Solar Micro Inverters Take Power at Night?",
duration: Some(2399), length: Some(2399),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/_R4wQQNSO6k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEQVZ0yQPLZqwLdQednKWwLWqDmA", url: "https://i.ytimg.com/vi/_R4wQQNSO6k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEQVZ0yQPLZqwLdQednKWwLWqDmA",
@ -1027,7 +1080,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1041,7 +1094,7 @@ Channel(
VideoItem( VideoItem(
id: "ikp5BorIo_M", id: "ikp5BorIo_M",
name: "EEVblog 1486 - What you DIDN\'T KNOW About Film Capacitor FAILURES!", name: "EEVblog 1486 - What you DIDN\'T KNOW About Film Capacitor FAILURES!",
duration: Some(1792), length: Some(1792),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/ikp5BorIo_M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBygGB8KC522NC15BhDC1WpuNKsgw", url: "https://i.ytimg.com/vi/ikp5BorIo_M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBygGB8KC522NC15BhDC1WpuNKsgw",
@ -1068,7 +1121,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1082,7 +1135,7 @@ Channel(
VideoItem( VideoItem(
id: "7O-QckjCXNo", id: "7O-QckjCXNo",
name: "eevBLAB 99 - AI SPAM BOT Youtube Space/Science/Tech Channels? - WTF", name: "eevBLAB 99 - AI SPAM BOT Youtube Space/Science/Tech Channels? - WTF",
duration: Some(592), length: Some(592),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/7O-QckjCXNo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBY1cRnrWQCbmlAzP5okMmIYjgdsg", url: "https://i.ytimg.com/vi/7O-QckjCXNo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBY1cRnrWQCbmlAzP5okMmIYjgdsg",
@ -1109,7 +1162,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1123,7 +1176,7 @@ Channel(
VideoItem( VideoItem(
id: "VutdTxF4E-0", id: "VutdTxF4E-0",
name: "RIP The Old Garage Lab", name: "RIP The Old Garage Lab",
duration: Some(115), length: Some(115),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/VutdTxF4E-0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDlPpT2-UOGfm2A2djTLjCsygeqSw", url: "https://i.ytimg.com/vi/VutdTxF4E-0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDlPpT2-UOGfm2A2djTLjCsygeqSw",
@ -1150,7 +1203,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1164,7 +1217,7 @@ Channel(
VideoItem( VideoItem(
id: "o7xfGuRaq94", id: "o7xfGuRaq94",
name: "EEVblog 1485 - PedalCell CadenceX Bike Generator LOL FAIL!", name: "EEVblog 1485 - PedalCell CadenceX Bike Generator LOL FAIL!",
duration: Some(1026), length: Some(1026),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/o7xfGuRaq94/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBA7RRL2USBwkYXp9ouWTbtU-JHSg", url: "https://i.ytimg.com/vi/o7xfGuRaq94/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBA7RRL2USBwkYXp9ouWTbtU-JHSg",
@ -1191,7 +1244,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1205,7 +1258,7 @@ Channel(
VideoItem( VideoItem(
id: "3WSIfHOv3fc", id: "3WSIfHOv3fc",
name: "EEVblog 1484 - Kaba Mas X-09 High Security Electronic Lock Teardown", name: "EEVblog 1484 - Kaba Mas X-09 High Security Electronic Lock Teardown",
duration: Some(1106), length: Some(1106),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/3WSIfHOv3fc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClZroFRo115ZuxYhJ5rcCDO2ZPcQ", url: "https://i.ytimg.com/vi/3WSIfHOv3fc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClZroFRo115ZuxYhJ5rcCDO2ZPcQ",
@ -1232,7 +1285,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1246,7 +1299,7 @@ Channel(
VideoItem( VideoItem(
id: "8yXZJZCKImI", id: "8yXZJZCKImI",
name: "EEVblog 1483 - Holy Mailbag Bomb Batman!", name: "EEVblog 1483 - Holy Mailbag Bomb Batman!",
duration: Some(3373), length: Some(3373),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/8yXZJZCKImI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBym7WfmrvKIjs2ClW-FOLtxbENzw", url: "https://i.ytimg.com/vi/8yXZJZCKImI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBym7WfmrvKIjs2ClW-FOLtxbENzw",
@ -1273,7 +1326,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1287,7 +1340,7 @@ Channel(
VideoItem( VideoItem(
id: "vJ4pW6LKJWU", id: "vJ4pW6LKJWU",
name: "EEVblog 1482 - Mains Capacitor Zener Regulator Circuit", name: "EEVblog 1482 - Mains Capacitor Zener Regulator Circuit",
duration: Some(1132), length: Some(1132),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/vJ4pW6LKJWU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDaKgfAJ4NAeqoMIPZDavsTw_JD5w", url: "https://i.ytimg.com/vi/vJ4pW6LKJWU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDaKgfAJ4NAeqoMIPZDavsTw_JD5w",
@ -1314,7 +1367,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(883000), subscriber_count: Some(883000),
)), )),
publish_date: "[date]", publish_date: "[date]",

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
handle: Some("@Coachella"),
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "//yt3.googleusercontent.com/RDZ6VWFjHEMFm_QcmCCf-yG_UiGo9YWXEmVRuiHSC8SvP02dgeBEtAjd4CnEKGLo0V2gGdIRDQ=s88-c-k-c0x00ffffff-no-rj-mo", url: "//yt3.googleusercontent.com/RDZ6VWFjHEMFm_QcmCCf-yG_UiGo9YWXEmVRuiHSC8SvP02dgeBEtAjd4CnEKGLo0V2gGdIRDQ=s88-c-k-c0x00ffffff-no-rj-mo",
@ -20,7 +18,7 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: verified, verification: Verified,
description: "April 14-16 & 21-23, 2023\n", description: "April 14-16 & 21-23, 2023\n",
tags: [ tags: [
"coachella", "coachella",
@ -33,7 +31,10 @@ Channel(
"indio", "indio",
"california", "california",
], ],
vanity_url: Some("https://www.youtube.com/@Coachella"),
banner: [], banner: [],
mobile_banner: [],
tv_banner: [],
has_shorts: true, has_shorts: true,
has_live: true, has_live: true,
visitor_data: Some("CgtjSUhDeVJ6SU5wNCj75uyhBg%3D%3D"), visitor_data: Some("CgtjSUhDeVJ6SU5wNCj75uyhBg%3D%3D"),
@ -43,7 +44,7 @@ Channel(
VideoItem( VideoItem(
id: "vFc_pAywtKc", id: "vFc_pAywtKc",
name: "The Murder Capital - Return My Head - Live at Coachella 2023", name: "The Murder Capital - Return My Head - Live at Coachella 2023",
duration: Some(194), length: Some(194),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/vFc_pAywtKc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAPDC5UHtj76ursSNJqBD-jAiSxHg", url: "https://i.ytimg.com/vi/vFc_pAywtKc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAPDC5UHtj76ursSNJqBD-jAiSxHg",
@ -70,7 +71,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -84,7 +85,7 @@ Channel(
VideoItem( VideoItem(
id: "3JprxZgfcHU", id: "3JprxZgfcHU",
name: "BENEE - Supaloney - ft. Gus Dapperton - Live at Coachella 2023", name: "BENEE - Supaloney - ft. Gus Dapperton - Live at Coachella 2023",
duration: Some(270), length: Some(270),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/3JprxZgfcHU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCs4cytf-M3ksr1YZB0Iu22b3_Baw", url: "https://i.ytimg.com/vi/3JprxZgfcHU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCs4cytf-M3ksr1YZB0Iu22b3_Baw",
@ -111,7 +112,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -125,7 +126,7 @@ Channel(
VideoItem( VideoItem(
id: "a4QufICobaA", id: "a4QufICobaA",
name: "Doechii - What It Is - Live at Coachella 2023", name: "Doechii - What It Is - Live at Coachella 2023",
duration: Some(185), length: Some(185),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/a4QufICobaA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC1bg4wXk4z0Tcp-PgPodKlRsf8lA", url: "https://i.ytimg.com/vi/a4QufICobaA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC1bg4wXk4z0Tcp-PgPodKlRsf8lA",
@ -152,7 +153,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -166,7 +167,7 @@ Channel(
VideoItem( VideoItem(
id: "QoRm-xhVqYU", id: "QoRm-xhVqYU",
name: "Gabriels - Blame - Live at Coachella 2023", name: "Gabriels - Blame - Live at Coachella 2023",
duration: Some(170), length: Some(170),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/QoRm-xhVqYU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD9H8nfnmu-G2jIfTelbBNbiAWvqw", url: "https://i.ytimg.com/vi/QoRm-xhVqYU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD9H8nfnmu-G2jIfTelbBNbiAWvqw",
@ -193,7 +194,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -207,7 +208,7 @@ Channel(
VideoItem( VideoItem(
id: "28DbQYSsn1w", id: "28DbQYSsn1w",
name: "Kaytranada - Intimidate - ft H.E.R. - Live at Coachella 2023", name: "Kaytranada - Intimidate - ft H.E.R. - Live at Coachella 2023",
duration: Some(252), length: Some(252),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/28DbQYSsn1w/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAF_nO2I3hjct93i3p6V3H1Rmadcg", url: "https://i.ytimg.com/vi/28DbQYSsn1w/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAF_nO2I3hjct93i3p6V3H1Rmadcg",
@ -234,7 +235,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -248,7 +249,7 @@ Channel(
VideoItem( VideoItem(
id: "nLFZFp3go3o", id: "nLFZFp3go3o",
name: "SG Lewis - Impact - ft. Channel Tres - Live at Coachella 2023", name: "SG Lewis - Impact - ft. Channel Tres - Live at Coachella 2023",
duration: Some(365), length: Some(365),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/nLFZFp3go3o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBgQvHlztxcmQ3pkFNMKQpgvgMusA", url: "https://i.ytimg.com/vi/nLFZFp3go3o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBgQvHlztxcmQ3pkFNMKQpgvgMusA",
@ -275,7 +276,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -289,7 +290,7 @@ Channel(
VideoItem( VideoItem(
id: "RWJMmYcPTR4", id: "RWJMmYcPTR4",
name: "MUNA - Silk Chiffon - Live at Coachella 2023", name: "MUNA - Silk Chiffon - Live at Coachella 2023",
duration: Some(220), length: Some(220),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/RWJMmYcPTR4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFk7y7WiMC9pZ9zE1YSlh0TA5o5Q", url: "https://i.ytimg.com/vi/RWJMmYcPTR4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFk7y7WiMC9pZ9zE1YSlh0TA5o5Q",
@ -316,7 +317,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -330,7 +331,7 @@ Channel(
VideoItem( VideoItem(
id: "gcrW53SoTKs", id: "gcrW53SoTKs",
name: "Pusha T - Diet Coke - Live at Coachella 2023", name: "Pusha T - Diet Coke - Live at Coachella 2023",
duration: Some(175), length: Some(175),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/gcrW53SoTKs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAlGMLr4aBbSqb-8HBAPeXGLtkOGg", url: "https://i.ytimg.com/vi/gcrW53SoTKs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAlGMLr4aBbSqb-8HBAPeXGLtkOGg",
@ -357,7 +358,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -371,7 +372,7 @@ Channel(
VideoItem( VideoItem(
id: "7pYqbVztRtk", id: "7pYqbVztRtk",
name: "Blink 182 - I Miss You - Live at Coachella 2023", name: "Blink 182 - I Miss You - Live at Coachella 2023",
duration: Some(267), length: Some(267),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/7pYqbVztRtk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD4rIf8atgIc2nEptj4CjgOPqXVWw", url: "https://i.ytimg.com/vi/7pYqbVztRtk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD4rIf8atgIc2nEptj4CjgOPqXVWw",
@ -398,7 +399,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -412,7 +413,7 @@ Channel(
VideoItem( VideoItem(
id: "yzmSlPiaeRU", id: "yzmSlPiaeRU",
name: "Blink 182 - Whats My Age Again - Live at Coachella 2023", name: "Blink 182 - Whats My Age Again - Live at Coachella 2023",
duration: Some(157), length: Some(157),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/yzmSlPiaeRU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzhu2omZ6arr3cGIEM1IGoIp_i3w", url: "https://i.ytimg.com/vi/yzmSlPiaeRU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzhu2omZ6arr3cGIEM1IGoIp_i3w",
@ -439,7 +440,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -453,7 +454,7 @@ Channel(
VideoItem( VideoItem(
id: "r3Kpm4lEXmg", id: "r3Kpm4lEXmg",
name: "Discover the Mirage, Part 2 - Coachella 2023", name: "Discover the Mirage, Part 2 - Coachella 2023",
duration: Some(96), length: Some(96),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/r3Kpm4lEXmg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBLfc6awfa8Mv7I1nTLxfJRY4XUKQ", url: "https://i.ytimg.com/vi/r3Kpm4lEXmg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBLfc6awfa8Mv7I1nTLxfJRY4XUKQ",
@ -480,7 +481,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -494,7 +495,7 @@ Channel(
VideoItem( VideoItem(
id: "LqrLCWoXR_k", id: "LqrLCWoXR_k",
name: "Coachella on YouTube 2023", name: "Coachella on YouTube 2023",
duration: Some(31), length: Some(31),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/LqrLCWoXR_k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCgX8ylcJLaYZiR3Nvr5WrS_6mw8g", url: "https://i.ytimg.com/vi/LqrLCWoXR_k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCgX8ylcJLaYZiR3Nvr5WrS_6mw8g",
@ -521,7 +522,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -535,7 +536,7 @@ Channel(
VideoItem( VideoItem(
id: "J1cmYPtABo0", id: "J1cmYPtABo0",
name: "Discover the Mirage, Part 1 - Coachella 2023", name: "Discover the Mirage, Part 1 - Coachella 2023",
duration: Some(91), length: Some(91),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/J1cmYPtABo0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAEDuvdZhNVkmvG-usGm9tmgJt7QQ", url: "https://i.ytimg.com/vi/J1cmYPtABo0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAEDuvdZhNVkmvG-usGm9tmgJt7QQ",
@ -562,7 +563,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -576,7 +577,7 @@ Channel(
VideoItem( VideoItem(
id: "a0BuUhI3f20", id: "a0BuUhI3f20",
name: "Coachella 2023 featuring Bad Bunny, BLACKPINK, Frank Ocean and more 🌵", name: "Coachella 2023 featuring Bad Bunny, BLACKPINK, Frank Ocean and more 🌵",
duration: Some(31), length: Some(31),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/a0BuUhI3f20/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUCg-MA8=&rs=AOn4CLBDwxWN_SrIR8rCSQVokx1wfe1iqQ", url: "https://i.ytimg.com/vi/a0BuUhI3f20/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUCg-MA8=&rs=AOn4CLBDwxWN_SrIR8rCSQVokx1wfe1iqQ",
@ -603,7 +604,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -617,7 +618,7 @@ Channel(
VideoItem( VideoItem(
id: "G2p-YqRGh80", id: "G2p-YqRGh80",
name: "MEUTE Interview Coachella Curated 2022", name: "MEUTE Interview Coachella Curated 2022",
duration: Some(224), length: Some(224),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/G2p-YqRGh80/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgSSg-MA8=&rs=AOn4CLBWAFod2tomSeOXcy3y5EOIjimn9g", url: "https://i.ytimg.com/vi/G2p-YqRGh80/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgSSg-MA8=&rs=AOn4CLBWAFod2tomSeOXcy3y5EOIjimn9g",
@ -644,7 +645,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -658,7 +659,7 @@ Channel(
VideoItem( VideoItem(
id: "eLZq4l37G7k", id: "eLZq4l37G7k",
name: "Belly - Interview - Coachella 2022", name: "Belly - Interview - Coachella 2022",
duration: Some(302), length: Some(302),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/eLZq4l37G7k/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgPyg6MA8=&rs=AOn4CLBtjJQRABeVsxDVsYK2RwoTETjE8A", url: "https://i.ytimg.com/vi/eLZq4l37G7k/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgPyg6MA8=&rs=AOn4CLBtjJQRABeVsxDVsYK2RwoTETjE8A",
@ -685,7 +686,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -699,7 +700,7 @@ Channel(
VideoItem( VideoItem(
id: "ViPAf8JpMXY", id: "ViPAf8JpMXY",
name: "Still Woozy - Interview - Coachella 2022", name: "Still Woozy - Interview - Coachella 2022",
duration: Some(304), length: Some(304),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/ViPAf8JpMXY/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUyhEMA8=&rs=AOn4CLBFMadm51TmtXHYl-3B3s1DS1NLoQ", url: "https://i.ytimg.com/vi/ViPAf8JpMXY/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUyhEMA8=&rs=AOn4CLBFMadm51TmtXHYl-3B3s1DS1NLoQ",
@ -726,7 +727,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -740,7 +741,7 @@ Channel(
VideoItem( VideoItem(
id: "4PKCIRUOZRE", id: "4PKCIRUOZRE",
name: "Slander - Interview - Coachella 2022", name: "Slander - Interview - Coachella 2022",
duration: Some(259), length: Some(259),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/4PKCIRUOZRE/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUChCMA8=&rs=AOn4CLD6iAmhCyMAwfcKJl18WeC_BrjyFQ", url: "https://i.ytimg.com/vi/4PKCIRUOZRE/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUChCMA8=&rs=AOn4CLD6iAmhCyMAwfcKJl18WeC_BrjyFQ",
@ -767,7 +768,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -781,7 +782,7 @@ Channel(
VideoItem( VideoItem(
id: "0O7abvoOxro", id: "0O7abvoOxro",
name: "Run The Jewels - Interview Coachella", name: "Run The Jewels - Interview Coachella",
duration: Some(408), length: Some(408),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/0O7abvoOxro/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgXihNMA8=&rs=AOn4CLCYxlSf_-9OXuvGCVfY8caFGVaGeQ", url: "https://i.ytimg.com/vi/0O7abvoOxro/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgXihNMA8=&rs=AOn4CLCYxlSf_-9OXuvGCVfY8caFGVaGeQ",
@ -808,7 +809,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -822,7 +823,7 @@ Channel(
VideoItem( VideoItem(
id: "z1Q7ahNLU9o", id: "z1Q7ahNLU9o",
name: "Rina Sawayama - Interview - Coachella 2022", name: "Rina Sawayama - Interview - Coachella 2022",
duration: Some(297), length: Some(297),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/z1Q7ahNLU9o/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWCg_MA8=&rs=AOn4CLAFBsN92p3Xd5jd75JOkVQmFpRaOQ", url: "https://i.ytimg.com/vi/z1Q7ahNLU9o/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWCg_MA8=&rs=AOn4CLAFBsN92p3Xd5jd75JOkVQmFpRaOQ",
@ -849,7 +850,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -863,7 +864,7 @@ Channel(
VideoItem( VideoItem(
id: "VB71WJvcdsM", id: "VB71WJvcdsM",
name: "Rich Brian - Interview - Coachella 2022", name: "Rich Brian - Interview - Coachella 2022",
duration: Some(371), length: Some(371),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/VB71WJvcdsM/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgVyhAMA8=&rs=AOn4CLAnNIA4THR0-WH60GnpECd_KRhUEQ", url: "https://i.ytimg.com/vi/VB71WJvcdsM/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgVyhAMA8=&rs=AOn4CLAnNIA4THR0-WH60GnpECd_KRhUEQ",
@ -890,7 +891,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -904,7 +905,7 @@ Channel(
VideoItem( VideoItem(
id: "FYr3OasngBI", id: "FYr3OasngBI",
name: "Masego - Interview - Coachella 2022", name: "Masego - Interview - Coachella 2022",
duration: Some(323), length: Some(323),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/FYr3OasngBI/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgXyg6MA8=&rs=AOn4CLAAT-2gUtrDLaKVDQmsUkKmkE__Lg", url: "https://i.ytimg.com/vi/FYr3OasngBI/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgXyg6MA8=&rs=AOn4CLAAT-2gUtrDLaKVDQmsUkKmkE__Lg",
@ -931,7 +932,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -945,7 +946,7 @@ Channel(
VideoItem( VideoItem(
id: "BwDnV5sbFeU", id: "BwDnV5sbFeU",
name: "Louis The Child - Interview - Coachella 2022", name: "Louis The Child - Interview - Coachella 2022",
duration: Some(360), length: Some(360),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/BwDnV5sbFeU/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWig2MA8=&rs=AOn4CLAXRG17JkByDUun5WIfMdVARqYwtg", url: "https://i.ytimg.com/vi/BwDnV5sbFeU/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWig2MA8=&rs=AOn4CLAXRG17JkByDUun5WIfMdVARqYwtg",
@ -972,7 +973,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -986,7 +987,7 @@ Channel(
VideoItem( VideoItem(
id: "iH8KFwkMurQ", id: "iH8KFwkMurQ",
name: "Kim Petras - Interview - Coachella 2022", name: "Kim Petras - Interview - Coachella 2022",
duration: Some(294), length: Some(294),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/iH8KFwkMurQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgVChIMA8=&rs=AOn4CLBzJcKvWEWZmdtorJ8P7tfMT1306A", url: "https://i.ytimg.com/vi/iH8KFwkMurQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgVChIMA8=&rs=AOn4CLBzJcKvWEWZmdtorJ8P7tfMT1306A",
@ -1013,7 +1014,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1027,7 +1028,7 @@ Channel(
VideoItem( VideoItem(
id: "NK96m-YTUaE", id: "NK96m-YTUaE",
name: "Joe Kay - Interview - Coachella 2022", name: "Joe Kay - Interview - Coachella 2022",
duration: Some(189), length: Some(189),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/NK96m-YTUaE/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWyhJMA8=&rs=AOn4CLD6ptJ2_2cwyY2pkGieoYscFjlVpQ", url: "https://i.ytimg.com/vi/NK96m-YTUaE/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWyhJMA8=&rs=AOn4CLD6ptJ2_2cwyY2pkGieoYscFjlVpQ",
@ -1054,7 +1055,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1068,7 +1069,7 @@ Channel(
VideoItem( VideoItem(
id: "jnG1qLK0SiI", id: "jnG1qLK0SiI",
name: "Japanese Breakfast - Interview - Coachella 2022", name: "Japanese Breakfast - Interview - Coachella 2022",
duration: Some(312), length: Some(312),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/jnG1qLK0SiI/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgWCguMA8=&rs=AOn4CLBTEvxp-kJ7uYZwIaiylaohW_7wGQ", url: "https://i.ytimg.com/vi/jnG1qLK0SiI/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgWCguMA8=&rs=AOn4CLBTEvxp-kJ7uYZwIaiylaohW_7wGQ",
@ -1095,7 +1096,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1109,7 +1110,7 @@ Channel(
VideoItem( VideoItem(
id: "NdKnb1e9_qA", id: "NdKnb1e9_qA",
name: "Idles - Interview - Coachella 2022", name: "Idles - Interview - Coachella 2022",
duration: Some(395), length: Some(395),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/NdKnb1e9_qA/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWihTMA8=&rs=AOn4CLBEHQoRUkshAo-28mmB520wlFwlxA", url: "https://i.ytimg.com/vi/NdKnb1e9_qA/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWihTMA8=&rs=AOn4CLBEHQoRUkshAo-28mmB520wlFwlxA",
@ -1136,7 +1137,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1150,7 +1151,7 @@ Channel(
VideoItem( VideoItem(
id: "o8LEidp-Dq8", id: "o8LEidp-Dq8",
name: "Freddie Gibbs - Interview - Coachella 2022", name: "Freddie Gibbs - Interview - Coachella 2022",
duration: Some(207), length: Some(207),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/o8LEidp-Dq8/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgNyhKMA8=&rs=AOn4CLBqrWHD5sKYIrl_Fj6dTSixhqFAbw", url: "https://i.ytimg.com/vi/o8LEidp-Dq8/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgNyhKMA8=&rs=AOn4CLBqrWHD5sKYIrl_Fj6dTSixhqFAbw",
@ -1177,7 +1178,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1191,7 +1192,7 @@ Channel(
VideoItem( VideoItem(
id: "4-sEy0jxh-U", id: "4-sEy0jxh-U",
name: "Epik High - Interview - Coachella 2022", name: "Epik High - Interview - Coachella 2022",
duration: Some(386), length: Some(386),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/4-sEy0jxh-U/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUCg9MA8=&rs=AOn4CLBqn7VHNUlbgYidF-k2x8b_W-_xWQ", url: "https://i.ytimg.com/vi/4-sEy0jxh-U/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUCg9MA8=&rs=AOn4CLBqn7VHNUlbgYidF-k2x8b_W-_xWQ",
@ -1218,7 +1219,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1232,7 +1233,7 @@ Channel(
VideoItem( VideoItem(
id: "YN5CjIFmx88", id: "YN5CjIFmx88",
name: "Duke Dumont - Interview - Coachella 2022", name: "Duke Dumont - Interview - Coachella 2022",
duration: Some(443), length: Some(443),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/YN5CjIFmx88/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgUyhCMA8=&rs=AOn4CLAPYvywgTRHRSLHZaQXLC1-pdsIIg", url: "https://i.ytimg.com/vi/YN5CjIFmx88/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgUyhCMA8=&rs=AOn4CLAPYvywgTRHRSLHZaQXLC1-pdsIIg",
@ -1259,7 +1260,7 @@ Channel(
id: "UCHF66aWLOxBW4l6VkSrS3cQ", id: "UCHF66aWLOxBW4l6VkSrS3cQ",
name: "Coachella", name: "Coachella",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(2710000), subscriber_count: Some(2710000),
)), )),
publish_date: "[date]", publish_date: "[date]",

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
handle: None,
subscriber_count: Some(880000), subscriber_count: Some(880000),
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
@ -25,7 +23,7 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: verified, verification: Verified,
description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA",
tags: [ tags: [
"electronics", "electronics",
@ -57,6 +55,7 @@ Channel(
"dumpster diving", "dumpster diving",
"debunking", "debunking",
], ],
vanity_url: Some("https://www.youtube.com/c/EevblogDave"),
banner: [ banner: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
@ -89,6 +88,60 @@ Channel(
height: 424, height: 424,
), ),
], ],
mobile_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: false, has_shorts: false,
has_live: false, has_live: false,
visitor_data: Some("CgszNU5rbDVZS2hMcyim4K2ZBg%3D%3D"), visitor_data: Some("CgszNU5rbDVZS2hMcyim4K2ZBg%3D%3D"),
@ -98,7 +151,7 @@ Channel(
VideoItem( VideoItem(
id: "gremHHvqYTE", id: "gremHHvqYTE",
name: "EEVblog 1501 - Rigol HDO4000 Low Noise 12bit Oscilloscope Unboxing & First Impression", name: "EEVblog 1501 - Rigol HDO4000 Low Noise 12bit Oscilloscope Unboxing & First Impression",
duration: Some(1794), length: Some(1794),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/gremHHvqYTE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBcwR0YIwLjfFam9HkKdkTkqx_gHw", url: "https://i.ytimg.com/vi/gremHHvqYTE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBcwR0YIwLjfFam9HkKdkTkqx_gHw",
@ -125,7 +178,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -139,7 +192,7 @@ Channel(
VideoItem( VideoItem(
id: "WHO8NBfpaO0", id: "WHO8NBfpaO0",
name: "eevBLAB 102 - Last Mile Autonomous Robot Deliveries WILL FAIL", name: "eevBLAB 102 - Last Mile Autonomous Robot Deliveries WILL FAIL",
duration: Some(742), length: Some(742),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/WHO8NBfpaO0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQPKMF3Aeo9CydEWz9pQWkn1Lu7Q", url: "https://i.ytimg.com/vi/WHO8NBfpaO0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQPKMF3Aeo9CydEWz9pQWkn1Lu7Q",
@ -166,7 +219,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -180,7 +233,7 @@ Channel(
VideoItem( VideoItem(
id: "W1Q8CxL95_Y", id: "W1Q8CxL95_Y",
name: "EEVblog 1500 - Automatic Transfer Switch REVERSE ENGINEERED", name: "EEVblog 1500 - Automatic Transfer Switch REVERSE ENGINEERED",
duration: Some(1770), length: Some(1770),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/W1Q8CxL95_Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBIxuct8vahJHOJTLfbOnsMOXnjvw", url: "https://i.ytimg.com/vi/W1Q8CxL95_Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBIxuct8vahJHOJTLfbOnsMOXnjvw",
@ -207,7 +260,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -221,7 +274,7 @@ Channel(
VideoItem( VideoItem(
id: "lagxSrPeoYg", id: "lagxSrPeoYg",
name: "EEVblog 1499 - EcoFlow Delta Pro 3.6kWh Portable Battery TEARDOWN!", name: "EEVblog 1499 - EcoFlow Delta Pro 3.6kWh Portable Battery TEARDOWN!",
duration: Some(2334), length: Some(2334),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/lagxSrPeoYg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAbAX2gdAF66O7BUCaOVg2vQOsS2Q", url: "https://i.ytimg.com/vi/lagxSrPeoYg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAbAX2gdAF66O7BUCaOVg2vQOsS2Q",
@ -248,7 +301,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -262,7 +315,7 @@ Channel(
VideoItem( VideoItem(
id: "qTctWW9_FmE", id: "qTctWW9_FmE",
name: "EEVblog 1498 - TransPod Fluxjet Hyperloop $550M Boondoggle!", name: "EEVblog 1498 - TransPod Fluxjet Hyperloop $550M Boondoggle!",
duration: Some(2399), length: Some(2399),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/qTctWW9_FmE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCbnEQaGGI5zD9lCJ8kMmciezX2kA", url: "https://i.ytimg.com/vi/qTctWW9_FmE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCbnEQaGGI5zD9lCJ8kMmciezX2kA",
@ -289,7 +342,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -303,7 +356,7 @@ Channel(
VideoItem( VideoItem(
id: "3t9G80wk0pk", id: "3t9G80wk0pk",
name: "eevBLAB 101 - Why Are Tektronix Oscilloscopes So Expensive?", name: "eevBLAB 101 - Why Are Tektronix Oscilloscopes So Expensive?",
duration: Some(1423), length: Some(1423),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/3t9G80wk0pk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnsVu-VQplpRpc1ZW-yk2byyZjZA", url: "https://i.ytimg.com/vi/3t9G80wk0pk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnsVu-VQplpRpc1ZW-yk2byyZjZA",
@ -330,7 +383,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -344,7 +397,7 @@ Channel(
VideoItem( VideoItem(
id: "7dze5CnZnmk", id: "7dze5CnZnmk",
name: "EEVblog 1497 - RIP Fluke. Thanks Energizer. NOT.", name: "EEVblog 1497 - RIP Fluke. Thanks Energizer. NOT.",
duration: Some(1168), length: Some(1168),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/7dze5CnZnmk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg430MYAmoycM4lbv_57S_d3kZRA", url: "https://i.ytimg.com/vi/7dze5CnZnmk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg430MYAmoycM4lbv_57S_d3kZRA",
@ -371,7 +424,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -385,7 +438,7 @@ Channel(
VideoItem( VideoItem(
id: "6XnrZpPYgBg", id: "6XnrZpPYgBg",
name: "EEVblog 1496 - Winning Mailbag", name: "EEVblog 1496 - Winning Mailbag",
duration: Some(3139), length: Some(3139),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/6XnrZpPYgBg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCrBgky13jB1p9xzKbmoUpJ4g0SNQ", url: "https://i.ytimg.com/vi/6XnrZpPYgBg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCrBgky13jB1p9xzKbmoUpJ4g0SNQ",
@ -412,7 +465,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -426,7 +479,7 @@ Channel(
VideoItem( VideoItem(
id: "Psp3ltpFvws", id: "Psp3ltpFvws",
name: "eevBLAB 100 - Reuters Attacks Odysee - LOL", name: "eevBLAB 100 - Reuters Attacks Odysee - LOL",
duration: Some(855), length: Some(855),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Psp3ltpFvws/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCu8Nu_NmDw5vBHgb7e8JdJR1Dr1Q", url: "https://i.ytimg.com/vi/Psp3ltpFvws/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCu8Nu_NmDw5vBHgb7e8JdJR1Dr1Q",
@ -453,7 +506,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -467,7 +520,7 @@ Channel(
VideoItem( VideoItem(
id: "taVYTYz5vLE", id: "taVYTYz5vLE",
name: "EEVblog 1495 - Quaze Wireless Power (AGAIN!) but for GAMING!", name: "EEVblog 1495 - Quaze Wireless Power (AGAIN!) but for GAMING!",
duration: Some(2592), length: Some(2592),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/taVYTYz5vLE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAMHngmN8TjWZz327vUD7zjjblYBw", url: "https://i.ytimg.com/vi/taVYTYz5vLE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAMHngmN8TjWZz327vUD7zjjblYBw",
@ -494,7 +547,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -508,7 +561,7 @@ Channel(
VideoItem( VideoItem(
id: "Y6cZrieFw-k", id: "Y6cZrieFw-k",
name: "EEVblog 1494 - FIVE Ways to Open a CHEAP SAFE!", name: "EEVblog 1494 - FIVE Ways to Open a CHEAP SAFE!",
duration: Some(1194), length: Some(1194),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Y6cZrieFw-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsdoJwcvSFZU4e9cwDFbZj3W21Pw", url: "https://i.ytimg.com/vi/Y6cZrieFw-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsdoJwcvSFZU4e9cwDFbZj3W21Pw",
@ -535,7 +588,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -549,7 +602,7 @@ Channel(
VideoItem( VideoItem(
id: "Kr2XyhpUdUI", id: "Kr2XyhpUdUI",
name: "EEVblog 1493 - MacGyver Project - Part 2", name: "EEVblog 1493 - MacGyver Project - Part 2",
duration: Some(1785), length: Some(1785),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/Kr2XyhpUdUI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdL3brjOzbABRuyz-yolawtGRsbw", url: "https://i.ytimg.com/vi/Kr2XyhpUdUI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdL3brjOzbABRuyz-yolawtGRsbw",
@ -576,7 +629,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -590,7 +643,7 @@ Channel(
VideoItem( VideoItem(
id: "rxGafdgkal8", id: "rxGafdgkal8",
name: "EEVblog 1492 - $5 Oscilloscope Repaired! + Oz GIVEAWAY", name: "EEVblog 1492 - $5 Oscilloscope Repaired! + Oz GIVEAWAY",
duration: Some(1163), length: Some(1163),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/rxGafdgkal8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD-p_t0q_Q2oTGyJuFCQJ5z6VPPMQ", url: "https://i.ytimg.com/vi/rxGafdgkal8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD-p_t0q_Q2oTGyJuFCQJ5z6VPPMQ",
@ -617,7 +670,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -631,7 +684,7 @@ Channel(
VideoItem( VideoItem(
id: "4yosozyeIP4", id: "4yosozyeIP4",
name: "EEVblog 1491 - The MacGyver Project - Part 1", name: "EEVblog 1491 - The MacGyver Project - Part 1",
duration: Some(1706), length: Some(1706),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/4yosozyeIP4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDRNAWkPQfuQirfiOdowD1iQlWrWg", url: "https://i.ytimg.com/vi/4yosozyeIP4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDRNAWkPQfuQirfiOdowD1iQlWrWg",
@ -658,7 +711,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -672,7 +725,7 @@ Channel(
VideoItem( VideoItem(
id: "06JtC2DC_dQ", id: "06JtC2DC_dQ",
name: "EEVblog 1490 - Insane Jaycar Dumpster Sale! 2022", name: "EEVblog 1490 - Insane Jaycar Dumpster Sale! 2022",
duration: Some(1700), length: Some(1700),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/06JtC2DC_dQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDVIvEssIKji_8dyBYGYbpIqen7vQ", url: "https://i.ytimg.com/vi/06JtC2DC_dQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDVIvEssIKji_8dyBYGYbpIqen7vQ",
@ -699,7 +752,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -713,7 +766,7 @@ Channel(
VideoItem( VideoItem(
id: "piquT76w9TI", id: "piquT76w9TI",
name: "EEVblog 1489 - Mystery Teardown!", name: "EEVblog 1489 - Mystery Teardown!",
duration: Some(1466), length: Some(1466),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/piquT76w9TI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCTzIcGeRDwUyINtik50EQCOTxwiA", url: "https://i.ytimg.com/vi/piquT76w9TI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCTzIcGeRDwUyINtik50EQCOTxwiA",
@ -740,7 +793,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -754,7 +807,7 @@ Channel(
VideoItem( VideoItem(
id: "pKuUKT-zU-g", id: "pKuUKT-zU-g",
name: "EEVblog 1488 - Tilt Five Augmented Reality AR Glasses - First Reaction!", name: "EEVblog 1488 - Tilt Five Augmented Reality AR Glasses - First Reaction!",
duration: Some(2152), length: Some(2152),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/pKuUKT-zU-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCB6Rh4miI20yPy2kJaxul_wA3Now", url: "https://i.ytimg.com/vi/pKuUKT-zU-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCB6Rh4miI20yPy2kJaxul_wA3Now",
@ -781,7 +834,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -795,7 +848,7 @@ Channel(
VideoItem( VideoItem(
id: "_R4wQQNSO6k", id: "_R4wQQNSO6k",
name: "EEVblog 1487 - Do Solar Micro Inverters Take Power at Night?", name: "EEVblog 1487 - Do Solar Micro Inverters Take Power at Night?",
duration: Some(2399), length: Some(2399),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/_R4wQQNSO6k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEQVZ0yQPLZqwLdQednKWwLWqDmA", url: "https://i.ytimg.com/vi/_R4wQQNSO6k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEQVZ0yQPLZqwLdQednKWwLWqDmA",
@ -822,7 +875,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -836,7 +889,7 @@ Channel(
VideoItem( VideoItem(
id: "ikp5BorIo_M", id: "ikp5BorIo_M",
name: "EEVblog 1486 - What you DIDN\'T KNOW About Film Capacitor FAILURES!", name: "EEVblog 1486 - What you DIDN\'T KNOW About Film Capacitor FAILURES!",
duration: Some(1792), length: Some(1792),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/ikp5BorIo_M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBygGB8KC522NC15BhDC1WpuNKsgw", url: "https://i.ytimg.com/vi/ikp5BorIo_M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBygGB8KC522NC15BhDC1WpuNKsgw",
@ -863,7 +916,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -877,7 +930,7 @@ Channel(
VideoItem( VideoItem(
id: "7O-QckjCXNo", id: "7O-QckjCXNo",
name: "eevBLAB 99 - AI SPAM BOT Youtube Space/Science/Tech Channels? - WTF", name: "eevBLAB 99 - AI SPAM BOT Youtube Space/Science/Tech Channels? - WTF",
duration: Some(592), length: Some(592),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/7O-QckjCXNo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBY1cRnrWQCbmlAzP5okMmIYjgdsg", url: "https://i.ytimg.com/vi/7O-QckjCXNo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBY1cRnrWQCbmlAzP5okMmIYjgdsg",
@ -904,7 +957,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -918,7 +971,7 @@ Channel(
VideoItem( VideoItem(
id: "VutdTxF4E-0", id: "VutdTxF4E-0",
name: "RIP The Old Garage Lab", name: "RIP The Old Garage Lab",
duration: Some(115), length: Some(115),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/VutdTxF4E-0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDlPpT2-UOGfm2A2djTLjCsygeqSw", url: "https://i.ytimg.com/vi/VutdTxF4E-0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDlPpT2-UOGfm2A2djTLjCsygeqSw",
@ -945,7 +998,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -959,7 +1012,7 @@ Channel(
VideoItem( VideoItem(
id: "o7xfGuRaq94", id: "o7xfGuRaq94",
name: "EEVblog 1485 - PedalCell CadenceX Bike Generator LOL FAIL!", name: "EEVblog 1485 - PedalCell CadenceX Bike Generator LOL FAIL!",
duration: Some(1026), length: Some(1026),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/o7xfGuRaq94/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBA7RRL2USBwkYXp9ouWTbtU-JHSg", url: "https://i.ytimg.com/vi/o7xfGuRaq94/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBA7RRL2USBwkYXp9ouWTbtU-JHSg",
@ -986,7 +1039,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1000,7 +1053,7 @@ Channel(
VideoItem( VideoItem(
id: "3WSIfHOv3fc", id: "3WSIfHOv3fc",
name: "EEVblog 1484 - Kaba Mas X-09 High Security Electronic Lock Teardown", name: "EEVblog 1484 - Kaba Mas X-09 High Security Electronic Lock Teardown",
duration: Some(1106), length: Some(1106),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/3WSIfHOv3fc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClZroFRo115ZuxYhJ5rcCDO2ZPcQ", url: "https://i.ytimg.com/vi/3WSIfHOv3fc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClZroFRo115ZuxYhJ5rcCDO2ZPcQ",
@ -1027,7 +1080,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1041,7 +1094,7 @@ Channel(
VideoItem( VideoItem(
id: "8yXZJZCKImI", id: "8yXZJZCKImI",
name: "EEVblog 1483 - Holy Mailbag Bomb Batman!", name: "EEVblog 1483 - Holy Mailbag Bomb Batman!",
duration: Some(3373), length: Some(3373),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/8yXZJZCKImI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBym7WfmrvKIjs2ClW-FOLtxbENzw", url: "https://i.ytimg.com/vi/8yXZJZCKImI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBym7WfmrvKIjs2ClW-FOLtxbENzw",
@ -1068,7 +1121,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1082,7 +1135,7 @@ Channel(
VideoItem( VideoItem(
id: "vJ4pW6LKJWU", id: "vJ4pW6LKJWU",
name: "EEVblog 1482 - Mains Capacitor Zener Regulator Circuit", name: "EEVblog 1482 - Mains Capacitor Zener Regulator Circuit",
duration: Some(1132), length: Some(1132),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/vJ4pW6LKJWU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDaKgfAJ4NAeqoMIPZDavsTw_JD5w", url: "https://i.ytimg.com/vi/vJ4pW6LKJWU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDaKgfAJ4NAeqoMIPZDavsTw_JD5w",
@ -1109,7 +1162,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1123,7 +1176,7 @@ Channel(
VideoItem( VideoItem(
id: "myqiqUE00fo", id: "myqiqUE00fo",
name: "EEVblog 1481 - Dodgy Dangerous Heater REPAIR", name: "EEVblog 1481 - Dodgy Dangerous Heater REPAIR",
duration: Some(1622), length: Some(1622),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/myqiqUE00fo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB3nqRnunVeYPk1_vdXP7IEv1E1Rg", url: "https://i.ytimg.com/vi/myqiqUE00fo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB3nqRnunVeYPk1_vdXP7IEv1E1Rg",
@ -1150,7 +1203,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1164,7 +1217,7 @@ Channel(
VideoItem( VideoItem(
id: "xIokNnjuam8", id: "xIokNnjuam8",
name: "EEVblog 1480 - Lightyear Zero Solar Powered Electric Car", name: "EEVblog 1480 - Lightyear Zero Solar Powered Electric Car",
duration: Some(1196), length: Some(1196),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/xIokNnjuam8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBRxCpyCftz0LJooMtxBcIWwaF6hw", url: "https://i.ytimg.com/vi/xIokNnjuam8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBRxCpyCftz0LJooMtxBcIWwaF6hw",
@ -1191,7 +1244,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1205,7 +1258,7 @@ Channel(
VideoItem( VideoItem(
id: "S3R4r2xvVYQ", id: "S3R4r2xvVYQ",
name: "EEVblog 1479 - Is Your Calculator WRONG?", name: "EEVblog 1479 - Is Your Calculator WRONG?",
duration: Some(1066), length: Some(1066),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/S3R4r2xvVYQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC2ZW-UUXJGrtHphT2E53pFafr-1g", url: "https://i.ytimg.com/vi/S3R4r2xvVYQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC2ZW-UUXJGrtHphT2E53pFafr-1g",
@ -1232,7 +1285,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1246,7 +1299,7 @@ Channel(
VideoItem( VideoItem(
id: "RlwcdUnRw6w", id: "RlwcdUnRw6w",
name: "EEVblog 1478 - Waveform Update Rate Shootout - Tek 2 Series vs Others", name: "EEVblog 1478 - Waveform Update Rate Shootout - Tek 2 Series vs Others",
duration: Some(1348), length: Some(1348),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/RlwcdUnRw6w/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYaH7c8-BP8807GgNGML2WUNK8pg", url: "https://i.ytimg.com/vi/RlwcdUnRw6w/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYaH7c8-BP8807GgNGML2WUNK8pg",
@ -1273,7 +1326,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",
@ -1287,7 +1340,7 @@ Channel(
VideoItem( VideoItem(
id: "R2fw2g6WFbg", id: "R2fw2g6WFbg",
name: "EEVblog 1477 - TEARDOWN! - NEW Tektronix 2 Series Oscilloscope", name: "EEVblog 1477 - TEARDOWN! - NEW Tektronix 2 Series Oscilloscope",
duration: Some(2718), length: Some(2718),
thumbnail: [ thumbnail: [
Thumbnail( Thumbnail(
url: "https://i.ytimg.com/vi/R2fw2g6WFbg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBwd6wqvFI0HcPpOkDW_XDzWSPH_w", url: "https://i.ytimg.com/vi/R2fw2g6WFbg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBwd6wqvFI0HcPpOkDW_XDzWSPH_w",
@ -1314,7 +1367,7 @@ Channel(
id: "UC2DjFE7Xf11URZqWBigcVOQ", id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog", name: "EEVblog",
avatar: [], avatar: [],
verification: verified, verification: Verified,
subscriber_count: Some(880000), subscriber_count: Some(880000),
)), )),
publish_date: "[date]", publish_date: "[date]",

View file

@ -5,9 +5,7 @@ expression: map_res.c
Channel( Channel(
id: "UCxBa895m48H5idw5li7h-0g", id: "UCxBa895m48H5idw5li7h-0g",
name: "Sebastian Figurroa", name: "Sebastian Figurroa",
handle: None,
subscriber_count: None, subscriber_count: None,
video_count: None,
avatar: [ avatar: [
Thumbnail( Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu_hsZ1XlUXHzXsGNHJw0np79WhWZcC4j8eFdy-tiUCDBKAjJyJOzE5kXFRiqL2S=s48-c-k-c0x00ffffff-no-rj", url: "https://yt3.ggpht.com/ytc/AMLnZu_hsZ1XlUXHzXsGNHJw0np79WhWZcC4j8eFdy-tiUCDBKAjJyJOzE5kXFRiqL2S=s48-c-k-c0x00ffffff-no-rj",
@ -25,10 +23,13 @@ Channel(
height: 176, height: 176,
), ),
], ],
verification: none, verification: None,
description: "", description: "",
tags: [], tags: [],
vanity_url: None,
banner: [], banner: [],
mobile_banner: [],
tv_banner: [],
has_shorts: false, has_shorts: false,
has_live: false, has_live: false,
visitor_data: Some("Cgtvc2s4UllvTGl6byigxseZBg%3D%3D"), visitor_data: Some("Cgtvc2s4UllvTGl6byigxseZBg%3D%3D"),

Some files were not shown because too many files have changed in this diff Show more