Compare commits
2 commits
main
...
feat/postp
Author | SHA1 | Date | |
---|---|---|---|
235017ba29 | |||
d9c45bb2e0 |
|
@ -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"}}'
|
|
|
@ -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/*
|
|
|
@ -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 }}"
|
|
|
@ -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
|
@ -4,5 +4,4 @@
|
||||||
*.snap.new
|
*.snap.new
|
||||||
|
|
||||||
rustypipe_reports
|
rustypipe_reports
|
||||||
rustypipe_cache*.json
|
rustypipe_cache.json
|
||||||
bg_snapshot.bin
|
|
||||||
|
|
|
@ -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
|
@ -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
|
336
CHANGELOG.md
|
@ -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 -->
|
|
155
Cargo.toml
|
@ -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"]
|
|
||||||
|
|
|
@ -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`)
|
|
66
Justfile
|
@ -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
|
@ -1,12 +1,9 @@
|
||||||
# 
|
# RustyPipe
|
||||||
|
|
||||||
[](https://crates.io/crates/rustypipe)
|
[](https://ci.thetadev.de/ThetaDev/rustypipe)
|
||||||
[](https://opensource.org/licenses/GPL-3.0)
|
|
||||||
[](https://docs.rs/rustypipe)
|
|
||||||
[](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`.
|
|
||||||
|
|
191
cli/CHANGELOG.md
|
@ -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 -->
|
|
|
@ -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"
|
|
||||||
|
|
174
cli/README.md
|
@ -1,174 +0,0 @@
|
||||||
#  CLI
|
|
||||||
|
|
||||||
[](https://crates.io/crates/rustypipe-cli)
|
|
||||||
[](https://opensource.org/licenses/GPL-3.0)
|
|
||||||
[](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.
|
|
1756
cli/src/main.rs
100
cliff.toml
|
@ -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
|
|
|
@ -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"] }
|
|
||||||
|
|
|
@ -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\""))
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
|
@ -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"),
|
||||||
},
|
},
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 -->
|
|
|
@ -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"]
|
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
#  Downloader
|
|
||||||
|
|
||||||
[](https://crates.io/crates/rustypipe-downloader)
|
|
||||||
[](https://opensource.org/licenses/GPL-3.0)
|
|
||||||
[](https://docs.rs/rustypipe-downloader)
|
|
||||||
[](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;
|
|
||||||
```
|
|
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
///
|
///
|
||||||
|
|
|
@ -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());
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
|
|
||||||

|

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

|

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

|

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

|

|
||||||
|
|
||||||
## [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**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**NEW**
|
**NEW**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## [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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## [9] Playlists for Shorts
|
|
||||||
|
|
||||||
- **Encountered on:** 26.06.2023
|
|
||||||
- **Impact:** 🟡 Medium
|
|
||||||
- **Endpoint:** browse (playlist)
|
|
||||||
- **Status:** Stabilized
|
|
||||||
|
|
||||||

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

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

|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
Before Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 171 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 550 KiB |
BIN
notes/logo.png
Before Width: | Height: | Size: 3.5 KiB |
110
notes/logo.svg
|
@ -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 |
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
179
postprocessor/src/ogg_from_webm.rs
Normal 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
|
@ -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(¤t_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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
23
src/cache.rs
|
@ -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!(
|
||||||
|
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1956
src/client/mod.rs
|
@ -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}"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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]",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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>>,
|
|
||||||
}
|
|
|
@ -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 {}
|
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::music_playlist::Contents;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(crate) struct MusicHistory {
|
|
||||||
pub contents: Contents,
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 #shorts–8 секунд – прайграць відэа",
|
||||||
|
"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 минута.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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"),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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]",
|
||||||
|
|
|
@ -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),
|
|
@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
|
@ -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, I’m 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 it’s because you’re 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, I’m 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 it’s because you’re 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]",
|
||||||
|
|
|
@ -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, I’m 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 it’s because you’re 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, I’m 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 it’s because you’re 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]",
|
||||||
|
|
|
@ -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]",
|
||||||
|
|
|
@ -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]",
|
||||||
|
|
|
@ -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]",
|
||||||
|
|
|
@ -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"),
|
||||||
|
|