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
|
||||
|
||||
rustypipe_reports
|
||||
rustypipe_cache*.json
|
||||
bg_snapshot.bin
|
||||
rustypipe_cache.json
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: check-json
|
||||
|
@ -10,8 +10,4 @@ repos:
|
|||
hooks:
|
||||
- id: cargo-fmt
|
||||
- id: cargo-clippy
|
||||
name: cargo-clippy rustypipe
|
||||
args: ["--package=rustypipe", "--tests", "--", "-D", "warnings"]
|
||||
- id: cargo-clippy
|
||||
name: cargo-clippy workspace
|
||||
args: ["--all", "--tests", "--features=rss,userdata,indicatif,audiotag", "--", "-D", "warnings"]
|
||||
args: ["--all", "--features=rss", "--", "-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]
|
||||
name = "rustypipe"
|
||||
version = "0.10.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]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["ThetaDev <thetadev@magenta.de>"]
|
||||
authors = ["ThetaDev <t.testboy@gmail.com>"]
|
||||
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"]
|
||||
categories = ["api-bindings", "multimedia"]
|
||||
|
||||
[workspace.dependencies]
|
||||
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"
|
||||
include = ["/src", "README.md", "LICENSE", "!snapshots"]
|
||||
|
||||
# CLI
|
||||
indicatif = "0.17.0"
|
||||
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",
|
||||
] }
|
||||
[workspace]
|
||||
members = [".", "codegen", "downloader", "postprocessor", "cli"]
|
||||
|
||||
[features]
|
||||
default = ["default-tls"]
|
||||
|
||||
rss = ["dep:quick-xml"]
|
||||
userdata = []
|
||||
rss = ["quick-xml"]
|
||||
|
||||
# Reqwest TLS options
|
||||
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"]
|
||||
|
||||
[dependencies]
|
||||
rquickjs.workspace = true
|
||||
once_cell.workspace = true
|
||||
regex.workspace = true
|
||||
fancy-regex.workspace = true
|
||||
thiserror.workspace = true
|
||||
url.workspace = true
|
||||
reqwest = { workspace = true, features = ["json", "gzip", "brotli"] }
|
||||
tokio = { workspace = true, features = ["macros", "time", "process"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_with.workspace = true
|
||||
serde_plain.workspace = true
|
||||
sha1.workspace = true
|
||||
rand.workspace = true
|
||||
time.workspace = true
|
||||
ress.workspace = true
|
||||
phf.workspace = true
|
||||
data-encoding.workspace = true
|
||||
urlencoding.workspace = true
|
||||
tracing.workspace = true
|
||||
localzone.workspace = true
|
||||
quick-xml = { workspace = true, optional = true }
|
||||
quick-js-dtp = { version = "0.4.1", default-features = false, features = [
|
||||
"patch-dateparser",
|
||||
] }
|
||||
once_cell = "1.12.0"
|
||||
regex = "1.6.0"
|
||||
fancy-regex = "0.11.0"
|
||||
thiserror = "1.0.36"
|
||||
url = "2.2.2"
|
||||
log = "0.4.17"
|
||||
reqwest = { version = "0.11.11", default-features = false, features = [
|
||||
"json",
|
||||
"gzip",
|
||||
"brotli",
|
||||
] }
|
||||
tokio = { version = "1.20.0", features = ["macros", "time"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.82"
|
||||
serde_with = { version = "3.0.0", default-features = false, features = [
|
||||
"macros",
|
||||
"json",
|
||||
] }
|
||||
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]
|
||||
rstest.workspace = true
|
||||
tokio-test.workspace = true
|
||||
insta.workspace = true
|
||||
path_macro.workspace = true
|
||||
tracing-test.workspace = true
|
||||
|
||||
[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"]
|
||||
env_logger = "0.10.0"
|
||||
test-log = "0.2.11"
|
||||
rstest = "0.17.0"
|
||||
tokio-test = "0.4.2"
|
||||
insta = { version = "1.17.1", features = ["ron", "redactions"] }
|
||||
path_macro = "1.0.0"
|
||||
|
|
|
@ -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:
|
||||
# cargo test --features=rss,userdata
|
||||
cargo nextest run --workspace --features=rss,userdata --no-fail-fast --retries 1 -- --skip 'user_data::'
|
||||
cargo test --features=rss
|
||||
|
||||
unittest:
|
||||
cargo nextest run --features=rss,userdata --no-fail-fast --lib
|
||||
cargo test --features=rss --lib
|
||||
|
||||
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:
|
||||
cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube
|
||||
|
||||
testyt-localized:
|
||||
YT_LANG=th cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube -- \
|
||||
--skip 'user_data::' --skip 'search_suggestion' --skip 'isrc_search_languages'
|
||||
testyt10:
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
for i in {1..10}; do \
|
||||
echo "---TEST RUN $i---"; \
|
||||
cargo test --features=rss --test youtube; \
|
||||
done
|
||||
|
||||
testintl:
|
||||
#!/usr/bin/env bash
|
||||
|
@ -32,8 +32,7 @@ testintl:
|
|||
for YT_LANG in "${LANGUAGES[@]}"; do
|
||||
echo "---TESTS FOR $YT_LANG ---"
|
||||
|
||||
if YT_LANG="$YT_LANG" cargo nextest run --no-fail-fast --retries 1 --test-threads 4 --test youtube -- \
|
||||
--skip 'user_data::' --skip 'search_suggestion' --skip 'isrc_search_languages' --skip 'resolve_'; then
|
||||
if YT_LANG="$YT_LANG" cargo test --test youtube -- --test-threads 4 --skip resolve; then
|
||||
echo "--- $YT_LANG COMPLETED ---"
|
||||
else
|
||||
echo "--- $YT_LANG FAILED ---"
|
||||
|
@ -48,45 +47,4 @@ testfiles:
|
|||
|
||||
report2yaml:
|
||||
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;
|
||||
|
||||
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"
|
||||
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;
|
||||
|
|
139
README.md
|
@ -1,12 +1,9 @@
|
|||
# 
|
||||
# RustyPipe
|
||||
|
||||
[](https://crates.io/crates/rustypipe)
|
||||
[](https://opensource.org/licenses/GPL-3.0)
|
||||
[](https://docs.rs/rustypipe)
|
||||
[](https://codeberg.org/ThetaDev/rustypipe/actions/?workflow=ci.yaml)
|
||||
[](https://ci.thetadev.de/ThetaDev/rustypipe)
|
||||
|
||||
RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music API
|
||||
(Innertube), inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||
Client for the public YouTube / YouTube Music API (Innertube), inspired by
|
||||
[NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||
|
||||
## Features
|
||||
|
||||
|
@ -21,8 +18,6 @@ RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music
|
|||
- **Search suggestions**
|
||||
- **Trending**
|
||||
- **URL resolver**
|
||||
- **Subscriptions**
|
||||
- **Playback history**
|
||||
|
||||
### YouTube Music
|
||||
|
||||
|
@ -36,35 +31,14 @@ RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music
|
|||
- **Moods/Genres**
|
||||
- **Charts**
|
||||
- **New** (albums, music videos)
|
||||
- **Saved items**
|
||||
- **Playback history**
|
||||
|
||||
## 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
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
rustypipe = "0.1.3"
|
||||
rustypipe = "0.1.0"
|
||||
tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
|
||||
```
|
||||
|
||||
|
@ -180,106 +154,3 @@ Subscribers: 1780000
|
|||
[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]
|
||||
name = "rustypipe-cli"
|
||||
version = "0.7.0"
|
||||
rust-version = "1.70.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["ThetaDev <t.testboy@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
description = "CLI for RustyPipe - download videos and extract data from YouTube / YouTube Music"
|
||||
keywords = ["youtube", "video", "music"]
|
||||
categories = ["multimedia"]
|
||||
|
||||
[features]
|
||||
default = ["native-tls"]
|
||||
timezone = ["dep:time", "dep:time-tz"]
|
||||
default = ["rustls-tls-native-roots"]
|
||||
|
||||
# Reqwest TLS options
|
||||
native-tls = [
|
||||
|
@ -42,29 +39,16 @@ rustls-tls-native-roots = [
|
|||
]
|
||||
|
||||
[dependencies]
|
||||
rustypipe = { workspace = true, features = ["rss", "userdata"] }
|
||||
rustypipe-downloader.workspace = true
|
||||
reqwest.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
futures-util.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
quick-xml.workspace = true
|
||||
time = { workspace = true, optional = true }
|
||||
time-tz = { version = "2.0.0", optional = true }
|
||||
|
||||
indicatif.workspace = true
|
||||
anyhow.workspace = true
|
||||
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"
|
||||
rustypipe = { path = "../", default-features = false }
|
||||
rustypipe-downloader = { path = "../downloader", default-features = false }
|
||||
reqwest = { version = "0.11.11", default_features = false }
|
||||
tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
|
||||
indicatif = "0.17.0"
|
||||
futures = "0.3.21"
|
||||
anyhow = "1.0"
|
||||
clap = { version = "4.0.29", features = ["derive"] }
|
||||
env_logger = "0.10.0"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0.82"
|
||||
serde_yaml = "0.9.19"
|
||||
dirs = "5.0.0"
|
||||
|
|
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.
|
1774
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]
|
||||
name = "rustypipe-codegen"
|
||||
version = "0.1.0"
|
||||
rust-version = "1.74.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
rustypipe = { path = "../", features = ["userdata"] }
|
||||
reqwest.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
futures-util.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_plain.workspace = true
|
||||
serde_with.workspace = true
|
||||
once_cell.workspace = true
|
||||
regex.workspace = true
|
||||
path_macro.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
phf_codegen.workspace = true
|
||||
indicatif.workspace = true
|
||||
|
||||
num_enum = "0.7.2"
|
||||
rustypipe = { path = "../" }
|
||||
reqwest = "0.11.11"
|
||||
tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
|
||||
futures = "0.3.21"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.82"
|
||||
serde_with = { version = "3.0.0", default-features = false, features = ["macros"] }
|
||||
anyhow = "1.0"
|
||||
log = "0.4.17"
|
||||
env_logger = "0.10.0"
|
||||
clap = { version = "4.0.29", features = ["derive"] }
|
||||
phf_codegen = "0.11.1"
|
||||
once_cell = "1.12.0"
|
||||
regex = "1.7.1"
|
||||
indicatif = "0.17.0"
|
||||
num_enum = "0.6.1"
|
||||
path_macro = "1.0.0"
|
||||
intl_pluralrules = "7.0.2"
|
||||
unic-langid = "0.9.1"
|
||||
ordered_hash_map = { version = "0.4.0", features = ["serde"] }
|
||||
|
|
|
@ -1,20 +1,15 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use futures_util::{stream, StreamExt};
|
||||
use futures::{stream, StreamExt};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use num_enum::TryFromPrimitive;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery};
|
||||
use rustypipe::model::{MusicItem, YouTubeItem};
|
||||
use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery, YTContext};
|
||||
use rustypipe::model::YouTubeItem;
|
||||
use rustypipe::param::search_filter::{ItemType, SearchFilter};
|
||||
use rustypipe::param::ChannelVideoTab;
|
||||
use serde::de::IgnoredAny;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::QCont;
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, TryFromPrimitive, Serialize, Deserialize,
|
||||
)]
|
||||
|
@ -26,26 +21,12 @@ pub enum ABTest {
|
|||
TrendsVideoTab = 4,
|
||||
TrendsPageHeaderRenderer = 5,
|
||||
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] = &[
|
||||
ABTest::MusicAlbumGroupsReordered,
|
||||
ABTest::MusicContinuationItemRenderer,
|
||||
const TESTS_TO_RUN: [ABTest; 3] = [
|
||||
ABTest::TrendsVideoTab,
|
||||
ABTest::TrendsPageHeaderRenderer,
|
||||
ABTest::DiscographyPage,
|
||||
];
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
@ -60,6 +41,7 @@ pub struct ABTestRes {
|
|||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct QVideo<'a> {
|
||||
context: YTContext<'a>,
|
||||
video_id: &'a str,
|
||||
content_check_ok: bool,
|
||||
racy_check_ok: bool,
|
||||
|
@ -68,6 +50,7 @@ struct QVideo<'a> {
|
|||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QBrowse<'a> {
|
||||
context: YTContext<'a>,
|
||||
browse_id: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<&'a str>,
|
||||
|
@ -82,6 +65,7 @@ pub async fn run_test(
|
|||
|
||||
let rp = RustyPipe::new();
|
||||
let pb = ProgressBar::new(n as u64);
|
||||
let http = reqwest::Client::default();
|
||||
pb.set_style(
|
||||
ProgressStyle::with_template(
|
||||
"{msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}",
|
||||
|
@ -93,8 +77,9 @@ pub async fn run_test(
|
|||
.map(|_| {
|
||||
let rp = rp.clone();
|
||||
let pb = pb.clone();
|
||||
let http = http.clone();
|
||||
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 is_present = match ab {
|
||||
ABTest::AttributedTextDescription => attributed_text_description(&query).await,
|
||||
|
@ -105,22 +90,6 @@ pub async fn run_test(
|
|||
ABTest::TrendsVideoTab => trends_video_tab(&query).await,
|
||||
ABTest::TrendsPageHeaderRenderer => trends_page_header_renderer(&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();
|
||||
pb.inc(1);
|
||||
|
@ -142,14 +111,30 @@ pub async fn run_test(
|
|||
(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> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
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 {
|
||||
id: *ab as u16,
|
||||
name: *ab,
|
||||
id: ab as u16,
|
||||
name: ab,
|
||||
tests: n,
|
||||
occurrences,
|
||||
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> {
|
||||
let context = rp.get_context(ClientType::Desktop, true, None).await;
|
||||
let q = QVideo {
|
||||
context,
|
||||
video_id: "ZeerrnuLi5E",
|
||||
content_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\"") {
|
||||
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> {
|
||||
let channel = rp.channel_videos("UCR-DXc1voovS8nhAvccRZhg").await?;
|
||||
let channel = rp.channel_videos("UCR-DXc1voovS8nhAvccRZhg").await.unwrap();
|
||||
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 {
|
||||
YouTubeItem::Channel(channel) => channel
|
||||
.subscriber_count
|
||||
.map(|sc| sc > 100 && channel.handle.is_some())
|
||||
.map(|sc| sc > 100 && channel.video_count.is_none())
|
||||
.unwrap_or_default(),
|
||||
_ => false,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn trends_video_tab(rp: &RustyPipeQuery) -> Result<bool> {
|
||||
let context = rp.get_context(ClientType::Desktop, true, None).await;
|
||||
let res = rp
|
||||
.raw(
|
||||
ClientType::Desktop,
|
||||
"browse",
|
||||
&QBrowse {
|
||||
context,
|
||||
browse_id: "FEtrending",
|
||||
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> {
|
||||
let context = rp.get_context(ClientType::Desktop, true, None).await;
|
||||
let res = rp
|
||||
.raw(
|
||||
ClientType::Desktop,
|
||||
"browse",
|
||||
&QBrowse {
|
||||
context,
|
||||
browse_id: "FEtrending",
|
||||
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> {
|
||||
let id = "UC7cl4MmM6ZZ2TcFyMk_b4pg";
|
||||
let res = rp
|
||||
.raw(
|
||||
ClientType::DesktopMusic,
|
||||
"browse",
|
||||
&QBrowse {
|
||||
browse_id: id,
|
||||
params: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(res.contains(&format!("\"MPAD{id}\"")))
|
||||
}
|
||||
|
||||
pub async fn short_date_format(rp: &RustyPipeQuery) -> Result<bool> {
|
||||
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\""))
|
||||
let artist = rp
|
||||
.music_artist("UC7cl4MmM6ZZ2TcFyMk_b4pg", false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(artist.albums.len() <= 10)
|
||||
}
|
||||
|
|
|
@ -1,41 +1,28 @@
|
|||
use std::{collections::BTreeMap, fs::File, io::BufReader};
|
||||
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use futures::stream::{self, StreamExt};
|
||||
use path_macro::path;
|
||||
use rustypipe::{
|
||||
client::{ClientType, RustyPipe, RustyPipeQuery},
|
||||
model::AlbumType,
|
||||
param::{Language, LANGUAGES},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::rust::deserialize_ignore_any;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
model::{ContentsRenderer, QBrowse, SectionList, Tab, TextRuns},
|
||||
model::{QBrowse, TextRuns},
|
||||
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) {
|
||||
let json_path = path!(*DICT_DIR / "album_type_samples.json");
|
||||
|
||||
let album_types = [
|
||||
(AlbumTypeX::Album, "MPREb_nlBWQROfvjo"),
|
||||
(AlbumTypeX::Single, "MPREb_bHfHGoy7vuv"),
|
||||
(AlbumTypeX::Ep, "MPREb_u1I69lSAe5v"),
|
||||
(AlbumTypeX::Audiobook, "MPREb_gaoNzsQHedo"),
|
||||
(AlbumTypeX::Show, "MPREb_cwzk8EUwypZ"),
|
||||
(AlbumType::Album, "MPREb_nlBWQROfvjo"),
|
||||
(AlbumType::Single, "MPREb_bHfHGoy7vuv"),
|
||||
(AlbumType::Ep, "MPREb_u1I69lSAe5v"),
|
||||
(AlbumType::Audiobook, "MPREb_gaoNzsQHedo"),
|
||||
(AlbumType::Show, "MPREb_cwzk8EUwypZ"),
|
||||
];
|
||||
|
||||
let rp = RustyPipe::new();
|
||||
|
@ -45,7 +32,7 @@ pub async fn collect_album_types(concurrency: usize) {
|
|||
let rp = rp.clone();
|
||||
async move {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
@ -84,7 +55,7 @@ pub fn write_samples_to_dict() {
|
|||
let json_path = path!(*DICT_DIR / "album_type_samples.json");
|
||||
|
||||
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();
|
||||
let mut dict = util::read_dict();
|
||||
let langs = dict.keys().copied().collect::<Vec<_>>();
|
||||
|
@ -96,12 +67,10 @@ pub fn write_samples_to_dict() {
|
|||
e_langs.push(lang);
|
||||
|
||||
for lang in &e_langs {
|
||||
collected.get(lang).unwrap().iter().for_each(|(t_str, v)| {
|
||||
let t =
|
||||
serde_plain::from_str::<AlbumType>(t_str.split('_').next().unwrap()).unwrap();
|
||||
collected.get(lang).unwrap().iter().for_each(|(t, v)| {
|
||||
dict_entry
|
||||
.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)]
|
||||
struct AlbumData {
|
||||
contents: AlbumContents,
|
||||
header: Header,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AlbumContents {
|
||||
two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<AlbumHeader>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AlbumHeader {
|
||||
music_responsive_header_renderer: HeaderRenderer,
|
||||
struct Header {
|
||||
music_detail_header_renderer: HeaderRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -132,7 +95,11 @@ struct HeaderRenderer {
|
|||
}
|
||||
|
||||
async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String {
|
||||
let context = query
|
||||
.get_context(ClientType::DesktopMusic, true, None)
|
||||
.await;
|
||||
let body = QBrowse {
|
||||
context,
|
||||
browse_id: id,
|
||||
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();
|
||||
|
||||
album
|
||||
.contents
|
||||
.two_column_browse_results_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
.music_responsive_header_renderer
|
||||
.header
|
||||
.music_detail_header_renderer
|
||||
.subtitle
|
||||
.runs
|
||||
.into_iter()
|
||||
|
@ -164,84 +119,3 @@ async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String {
|
|||
.unwrap()
|
||||
.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 futures_util::{stream, StreamExt};
|
||||
use futures::{stream, StreamExt};
|
||||
use once_cell::sync::Lazy;
|
||||
use path_macro::path;
|
||||
use regex::Regex;
|
||||
|
@ -350,6 +350,7 @@ async fn get_channel(query: &RustyPipeQuery, channel_id: &str) -> Result<Channel
|
|||
ClientType::Desktop,
|
||||
"browse",
|
||||
&QBrowse {
|
||||
context: query.get_context(ClientType::Desktop, true, None).await,
|
||||
browse_id: channel_id,
|
||||
params: Some("EgZ2aWRlb3MYASAAMAE"),
|
||||
},
|
||||
|
@ -391,6 +392,7 @@ async fn get_channel(query: &RustyPipeQuery, channel_id: &str) -> Result<Channel
|
|||
ClientType::Desktop,
|
||||
"browse",
|
||||
&QCont {
|
||||
context: query.get_context(ClientType::Desktop, true, None).await,
|
||||
continuation: &popular_token,
|
||||
},
|
||||
)
|
||||
|
@ -429,6 +431,9 @@ async fn music_channel_subscribers(query: &RustyPipeQuery, channel_id: &str) ->
|
|||
ClientType::DesktopMusic,
|
||||
"browse",
|
||||
&QBrowse {
|
||||
context: query
|
||||
.get_context(ClientType::DesktopMusic, true, None)
|
||||
.await,
|
||||
browse_id: channel_id,
|
||||
params: None,
|
||||
},
|
||||
|
|
|
@ -5,8 +5,7 @@ use std::{
|
|||
io::BufReader,
|
||||
};
|
||||
|
||||
use futures_util::{stream, StreamExt};
|
||||
use ordered_hash_map::OrderedHashMap;
|
||||
use futures::{stream, StreamExt};
|
||||
use path_macro::path;
|
||||
use rustypipe::{
|
||||
client::RustyPipe,
|
||||
|
@ -66,9 +65,9 @@ pub async fn collect_dates(concurrency: usize) {
|
|||
|
||||
// These are the sample playlists
|
||||
let cases = [
|
||||
(DateCase::Today, "PL3oW2tjiIxvQ98ZTLhBh5soCbE1mC3uAT"),
|
||||
(DateCase::Yesterday, "PLGBuKfnErZlCkRRgt06em8nbXvcV5Sae7"),
|
||||
(DateCase::Ago, "PLAQ7nLSEnhWTEihjeM1I-ToPDJEKfZHZu"),
|
||||
(DateCase::Today, "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj"),
|
||||
(DateCase::Yesterday, "PL3oW2tjiIxvQ98ZTLhBh5soCbE1mC3uAT"),
|
||||
(DateCase::Ago, "PLeDakahyfrO9Amk2GFrzpI4UWOkgqzoIE"),
|
||||
(DateCase::Jan, "PL1J-6JOckZtFjcni6Xj1pLYglJp6JCpKD"),
|
||||
(DateCase::Feb, "PL1J-6JOckZtETrbzwZE7mRIIK6BzWNLAs"),
|
||||
(DateCase::Mar, "PL1J-6JOckZtG3AVdvBXhMO64mB2k3BtKi"),
|
||||
|
@ -171,7 +170,7 @@ pub fn write_samples_to_dict() {
|
|||
dict_entry.months = BTreeMap::new();
|
||||
|
||||
if collect_nd_tokens {
|
||||
dict_entry.timeago_nd_tokens = OrderedHashMap::new();
|
||||
dict_entry.timeago_nd_tokens = BTreeMap::new();
|
||||
}
|
||||
|
||||
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 futures_util::{stream, StreamExt};
|
||||
use futures::{stream, StreamExt};
|
||||
use path_macro::path;
|
||||
use rustypipe::{
|
||||
client::{ClientType, RustyPipe, RustyPipeQuery},
|
||||
|
@ -270,6 +270,7 @@ async fn get_channel_vlengths(
|
|||
ClientType::Desktop,
|
||||
"browse",
|
||||
&QBrowse {
|
||||
context: query.get_context(ClientType::Desktop, true, None).await,
|
||||
browse_id: channel_id,
|
||||
params: Some("EgZ2aWRlb3MYASAAMAE"),
|
||||
},
|
||||
|
|
|
@ -8,7 +8,6 @@ use std::{
|
|||
use path_macro::path;
|
||||
use rustypipe::{
|
||||
client::{ClientType, RustyPipe},
|
||||
model::YouTubeItem,
|
||||
param::{
|
||||
search_filter::{self, ItemType, SearchFilter},
|
||||
ChannelVideoTab, Country,
|
||||
|
@ -38,6 +37,8 @@ pub async fn download_testfiles() {
|
|||
search_cont().await;
|
||||
search_playlists().await;
|
||||
search_empty().await;
|
||||
startpage().await;
|
||||
startpage_cont().await;
|
||||
trending().await;
|
||||
|
||||
music_playlist().await;
|
||||
|
@ -62,23 +63,12 @@ pub async fn download_testfiles() {
|
|||
music_charts().await;
|
||||
music_genres().await;
|
||||
music_genre().await;
|
||||
|
||||
// User data
|
||||
history().await;
|
||||
subscriptions().await;
|
||||
subscription_feed().await;
|
||||
|
||||
music_history().await;
|
||||
music_saved_artists().await;
|
||||
music_saved_albums().await;
|
||||
music_saved_tracks().await;
|
||||
music_saved_playlists().await;
|
||||
}
|
||||
|
||||
const CLIENT_TYPES: [ClientType; 5] = [
|
||||
ClientType::Desktop,
|
||||
ClientType::DesktopMusic,
|
||||
ClientType::Tv,
|
||||
ClientType::TvHtml5Embed,
|
||||
ClientType::Android,
|
||||
ClientType::Ios,
|
||||
];
|
||||
|
@ -144,7 +134,6 @@ fn rp_testfile(json_path: &Path) -> RustyPipe {
|
|||
.report()
|
||||
.strict()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn player() {
|
||||
|
@ -166,7 +155,7 @@ async fn player() {
|
|||
}
|
||||
|
||||
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")] {
|
||||
let json_path =
|
||||
|
@ -402,10 +391,7 @@ async fn search() {
|
|||
}
|
||||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query()
|
||||
.search::<YouTubeItem, _>("doobydoobap")
|
||||
.await
|
||||
.unwrap();
|
||||
rp.query().search("doobydoobap").await.unwrap();
|
||||
}
|
||||
|
||||
async fn search_cont() {
|
||||
|
@ -415,11 +401,7 @@ async fn search_cont() {
|
|||
}
|
||||
|
||||
let rp = RustyPipe::new();
|
||||
let search = rp
|
||||
.query()
|
||||
.search::<YouTubeItem, _>("doobydoobap")
|
||||
.await
|
||||
.unwrap();
|
||||
let search = rp.query().search("doobydoobap").await.unwrap();
|
||||
|
||||
let rp = rp_testfile(&json_path);
|
||||
search.items.next(rp.query()).await.unwrap().unwrap();
|
||||
|
@ -433,7 +415,7 @@ async fn search_playlists() {
|
|||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query()
|
||||
.search_filter::<YouTubeItem, _>("pop", &SearchFilter::new().item_type(ItemType::Playlist))
|
||||
.search_filter("pop", &SearchFilter::new().item_type(ItemType::Playlist))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
@ -446,7 +428,7 @@ async fn search_empty() {
|
|||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query()
|
||||
.search_filter::<YouTubeItem, _>(
|
||||
.search_filter(
|
||||
"test",
|
||||
&SearchFilter::new()
|
||||
.feature(search_filter::Feature::IsLive)
|
||||
|
@ -456,6 +438,29 @@ async fn search_empty() {
|
|||
.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() {
|
||||
let json_path = path!(*TESTFILES_DIR / "trends" / "trending_videos.json");
|
||||
if json_path.exists() {
|
||||
|
@ -466,36 +471,6 @@ async fn trending() {
|
|||
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() {
|
||||
for (name, id) in [
|
||||
("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"),
|
||||
|
@ -582,7 +557,7 @@ async fn music_search() {
|
|||
}
|
||||
|
||||
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);
|
||||
rp.query()
|
||||
.music_search_playlists("pop", community)
|
||||
.music_search_playlists_filter("pop", community)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
@ -817,53 +792,3 @@ async fn music_genre() {
|
|||
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>) {
|
||||
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) {
|
||||
Some(cap) => (
|
||||
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),
|
||||
"M" => Some(TimeUnit::Month),
|
||||
"Y" => Some(TimeUnit::Year),
|
||||
"Wl" => Some(TimeUnit::LastWeek),
|
||||
"Wd" => Some(TimeUnit::LastWeekday),
|
||||
"" => None,
|
||||
_ => panic!("invalid time unit: {tu}"),
|
||||
},
|
||||
|
@ -45,7 +43,7 @@ pub fn generate_dictionary() {
|
|||
use crate::{
|
||||
model::AlbumType,
|
||||
param::Language,
|
||||
util::timeago::{TaToken, TimeUnit},
|
||||
util::timeago::{DateCmp, TaToken, TimeUnit},
|
||||
};
|
||||
|
||||
/// Dictionary entry containing language-specific parsing information
|
||||
|
@ -57,13 +55,14 @@ pub(crate) struct Entry {
|
|||
/// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay),
|
||||
/// `h`(our), `m`(inute), `s`(econd)
|
||||
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:
|
||||
///
|
||||
/// - 03.01.2020 => DMY => false
|
||||
/// - 01/03/2020 => MDY => true
|
||||
pub month_before_day: bool,
|
||||
/// - 03.01.2020 => `"DMY"`
|
||||
/// - Jan 3, 2020 => `"DY"`
|
||||
pub date_order: &'static [DateCmp],
|
||||
/// Tokens for parsing month names.
|
||||
///
|
||||
/// Format: Parsed token -> Month number (starting from 1)
|
||||
|
@ -86,10 +85,6 @@ pub(crate) struct Entry {
|
|||
///
|
||||
/// Format: Parsed text -> Album type
|
||||
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
|
||||
let mut number_tokens = phf_codegen::Map::<&str>::new();
|
||||
entry.number_tokens.iter().for_each(|(txt, mag)| {
|
||||
|
@ -178,8 +180,8 @@ pub(crate) fn entry(lang: Language) -> Entry {
|
|||
.to_string()
|
||||
.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 ",
|
||||
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();
|
||||
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, 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";
|
||||
|
|
|
@ -202,20 +202,11 @@ pub enum Country {
|
|||
.to_owned();
|
||||
|
||||
let mut code_lang_array = format!(
|
||||
r#"/// Array of all available languages
|
||||
/// 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; {}] = [
|
||||
"#,
|
||||
"/// Array of all available languages\npub const LANGUAGES: [Language; {}] = [\n",
|
||||
languages.len()
|
||||
);
|
||||
let mut code_country_array = format!(
|
||||
r#"/// Array of all available countries
|
||||
///
|
||||
/// 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; {}] = [
|
||||
"#,
|
||||
"/// Array of all available countries\npub const COUNTRIES: [Country; {}] = [\n",
|
||||
countries.len()
|
||||
);
|
||||
|
||||
|
@ -237,15 +228,16 @@ pub const COUNTRIES: [Country; {}] = [
|
|||
.to_owned();
|
||||
|
||||
for (code, native_name) in &languages {
|
||||
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
|
||||
});
|
||||
let enum_name = code
|
||||
.split('-')
|
||||
.map(|c| {
|
||||
format!(
|
||||
"{}{}",
|
||||
c[0..1].to_owned().to_uppercase(),
|
||||
c[1..].to_owned().to_lowercase()
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
let en_name = lang_names.get(code).expect(code);
|
||||
|
||||
|
@ -261,6 +253,9 @@ pub const COUNTRIES: [Country; {}] = [
|
|||
code_langs += &enum_name;
|
||||
code_langs += ",\n";
|
||||
|
||||
// Language array
|
||||
writeln!(code_lang_array, " Language::{enum_name},").unwrap();
|
||||
|
||||
// Language names
|
||||
writeln!(
|
||||
code_lang_names,
|
||||
|
@ -270,24 +265,6 @@ pub const COUNTRIES: [Country; {}] = [
|
|||
}
|
||||
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 {
|
||||
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, " {enum_name},").unwrap();
|
||||
|
||||
// Country array
|
||||
writeln!(code_country_array, " Country::{enum_name},").unwrap();
|
||||
|
||||
// Country names
|
||||
writeln!(
|
||||
code_country_names,
|
||||
|
@ -303,16 +283,6 @@ pub const COUNTRIES: [Country; {}] = [
|
|||
.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
|
||||
code_countries += " /// Global (can only be used for music charts)\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")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.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
|
||||
.unwrap()
|
||||
|
|
|
@ -2,11 +2,8 @@
|
|||
|
||||
mod abtest;
|
||||
mod collect_album_types;
|
||||
mod collect_chan_prefixes;
|
||||
mod collect_history_dates;
|
||||
mod collect_large_numbers;
|
||||
mod collect_playlist_dates;
|
||||
mod collect_video_dates;
|
||||
mod collect_video_durations;
|
||||
mod download_testfiles;
|
||||
mod gen_dictionary;
|
||||
|
@ -30,16 +27,10 @@ enum Commands {
|
|||
CollectLargeNumbers,
|
||||
CollectAlbumTypes,
|
||||
CollectVideoDurations,
|
||||
CollectVideoDates,
|
||||
CollectHistoryDates,
|
||||
CollectMusicHistoryDates,
|
||||
CollectChanPrefixes,
|
||||
ParsePlaylistDates,
|
||||
ParseHistoryDates,
|
||||
ParseLargeNumbers,
|
||||
ParseAlbumTypes,
|
||||
ParseVideoDurations,
|
||||
ParseChanPrefixes,
|
||||
GenLocales,
|
||||
GenDict,
|
||||
DownloadTestfiles,
|
||||
|
@ -53,7 +44,7 @@ enum Commands {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
env_logger::init();
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
|
@ -69,24 +60,10 @@ async fn main() {
|
|||
Commands::CollectVideoDurations => {
|
||||
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::ParseHistoryDates => collect_history_dates::write_samples_to_dict(),
|
||||
Commands::ParseLargeNumbers => collect_large_numbers::write_samples_to_dict(),
|
||||
Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(),
|
||||
Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(),
|
||||
Commands::ParseChanPrefixes => collect_chan_prefixes::write_samples_to_dict(),
|
||||
Commands::GenLocales => {
|
||||
gen_locales::generate_locales().await;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use ordered_hash_map::OrderedHashMap;
|
||||
use rustypipe::{model::AlbumType, param::Language};
|
||||
use rustypipe::{client::YTContext, model::AlbumType, param::Language};
|
||||
use serde::{Deserialize, Serialize};
|
||||
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?
|
||||
/// (e.g. Chinese/Japanese)
|
||||
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.
|
||||
///
|
||||
/// Format: Parsed token -> \[Quantity\] Identifier
|
||||
///
|
||||
/// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay),
|
||||
/// `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
|
||||
/// 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)
|
||||
///
|
||||
/// 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?
|
||||
pub comma_decimal: bool,
|
||||
/// Tokens for parsing decimal prefixes (K, M, B, ...)
|
||||
|
@ -57,10 +49,6 @@ pub struct DictEntry {
|
|||
///
|
||||
/// Format: Parsed text -> Album type
|
||||
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.
|
||||
|
@ -74,12 +62,12 @@ pub struct TimeAgo {
|
|||
pub unit: TimeUnit,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TimeAgo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
impl ToString for TimeAgo {
|
||||
fn to_string(&self) -> String {
|
||||
if self.n > 1 {
|
||||
write!(f, "{}{}", self.n, self.unit.as_str())
|
||||
format!("{}{}", self.n, self.unit.as_str())
|
||||
} else {
|
||||
f.write_str(self.unit.as_str())
|
||||
self.unit.as_str().to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -95,8 +83,6 @@ pub enum TimeUnit {
|
|||
Week,
|
||||
Month,
|
||||
Year,
|
||||
LastWeek,
|
||||
LastWeekday,
|
||||
}
|
||||
|
||||
impl TimeUnit {
|
||||
|
@ -109,24 +95,14 @@ impl TimeUnit {
|
|||
TimeUnit::Week => "W",
|
||||
TimeUnit::Month => "M",
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QBrowse<'a> {
|
||||
pub context: YTContext<'a>,
|
||||
pub browse_id: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<&'a str>,
|
||||
|
@ -135,6 +111,7 @@ pub struct QBrowse<'a> {
|
|||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QCont<'a> {
|
||||
pub context: YTContext<'a>,
|
||||
pub continuation: &'a str,
|
||||
}
|
||||
|
||||
|
@ -152,7 +129,7 @@ pub struct Text {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Channel {
|
||||
pub contents: TwoColumnBrowseResults,
|
||||
pub contents: Contents,
|
||||
pub header: ChannelHeader,
|
||||
}
|
||||
|
||||
|
@ -170,7 +147,7 @@ pub struct HeaderRenderer {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TwoColumnBrowseResults {
|
||||
pub struct Contents {
|
||||
pub two_column_browse_results_renderer: TabsRenderer,
|
||||
}
|
||||
|
||||
|
@ -179,37 +156,24 @@ pub struct TwoColumnBrowseResults {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabsRenderer {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub tabs: Vec<Tab<RichGrid>>,
|
||||
pub tabs: Vec<TabRendererWrap>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentsRenderer<T> {
|
||||
#[serde(alias = "tabs")]
|
||||
pub contents: Vec<T>,
|
||||
pub struct TabRendererWrap {
|
||||
pub tab_renderer: TabRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tab<T> {
|
||||
pub tab_renderer: TabRenderer<T>,
|
||||
pub struct TabRenderer {
|
||||
pub content: RichGridRendererWrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabRenderer<T> {
|
||||
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 struct RichGridRendererWrap {
|
||||
pub rich_grid_renderer: RichGridRenderer,
|
||||
}
|
||||
|
||||
|
|
|
@ -77,7 +77,7 @@ pub fn filter_datestr(string: &str) -> String {
|
|||
.to_lowercase()
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() {
|
||||
if c == '\u{200b}' || c.is_ascii_digit() {
|
||||
None
|
||||
} else if c == '-' {
|
||||
Some(' ')
|
||||
|
@ -134,16 +134,12 @@ where
|
|||
if c.is_ascii_digit() {
|
||||
buf.push(c);
|
||||
} else if !buf.is_empty() {
|
||||
if let Ok(n) = buf.parse::<F>() {
|
||||
numbers.push(n);
|
||||
}
|
||||
buf.parse::<F>().map_or((), |n| numbers.push(n));
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
if let Ok(n) = buf.parse::<F>() {
|
||||
numbers.push(n);
|
||||
}
|
||||
buf.parse::<F>().map_or((), |n| numbers.push(n));
|
||||
}
|
||||
|
||||
numbers
|
||||
|
@ -190,7 +186,7 @@ pub fn parse_largenum_en(string: &str) -> Option<u64> {
|
|||
/// and return the duration in seconds.
|
||||
pub fn parse_video_length(text: &str) -> Option<u32> {
|
||||
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| {
|
||||
let hrs = cap
|
||||
.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]
|
||||
name = "rustypipe-downloader"
|
||||
version = "0.3.0"
|
||||
rust-version = "1.67.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["ThetaDev <t.testboy@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
description = "Downloader extension for RustyPipe"
|
||||
keywords = ["youtube", "video", "music"]
|
||||
categories = ["multimedia"]
|
||||
|
||||
[features]
|
||||
default = ["default-tls"]
|
||||
|
@ -30,37 +28,18 @@ rustls-tls-native-roots = [
|
|||
"rustypipe/rustls-tls-native-roots",
|
||||
]
|
||||
|
||||
audiotag = ["dep:lofty", "dep:image", "dep:smartcrop2"]
|
||||
|
||||
[dependencies]
|
||||
rustypipe.workspace = true
|
||||
once_cell.workspace = true
|
||||
regex.workspace = true
|
||||
thiserror.workspace = true
|
||||
futures-util.workspace = true
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
rand.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "fs", "process"] }
|
||||
indicatif = { workspace = true, optional = true }
|
||||
filenamify.workspace = true
|
||||
tracing.workspace = true
|
||||
time.workspace = true
|
||||
lofty = { version = "0.22.0", optional = true }
|
||||
image = { version = "0.25.0", optional = true, default-features = false, features = [
|
||||
"rayon",
|
||||
"jpeg",
|
||||
"webp",
|
||||
rustypipe = { path = "..", default-features = false }
|
||||
rustypipe-postprocessor = { path = "../postprocessor" }
|
||||
once_cell = "1.12.0"
|
||||
regex = "1.6.0"
|
||||
thiserror = "1.0.36"
|
||||
futures = "0.3.21"
|
||||
indicatif = "0.17.0"
|
||||
filenamify = "0.1.0"
|
||||
log = "0.4.17"
|
||||
reqwest = { version = "0.11.11", default-features = false, features = [
|
||||
"stream",
|
||||
] }
|
||||
smartcrop2 = { version = "0.3.1", optional = true }
|
||||
|
||||
[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"]
|
||||
rand = "0.8.5"
|
||||
tokio = { version = "1.20.0", features = ["macros", "fs", "process"] }
|
||||
|
|
|
@ -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 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
|
||||
///
|
||||
|
|
|
@ -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
|
||||
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
|
||||
returned in the `responseContext.visitorData` response parameter and stored as the
|
||||
`__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.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
- **Impact:** 🟡 Medium
|
||||
- **Endpoint:** next (video details)
|
||||
- **Status:** Stabilized
|
||||
|
||||

|
||||
|
||||
|
@ -130,7 +121,6 @@ UTF-16 index seperately.
|
|||
- **Encountered on:** 11.10.2022
|
||||
- **Impact:** 🔴 High
|
||||
- **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
|
||||
- **Impact:** 🟡 Medium
|
||||
- **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
|
||||
- **Impact:** 🟢 Low
|
||||
- **Endpoint:** browse (trending videos)
|
||||
- **Status:** Discontinued
|
||||
|
||||
YouTube moved the list of trending videos from the main _trending_ page to a separate
|
||||
tab (Videos).
|
||||
|
@ -305,14 +293,13 @@ The data model for the video shelves did not change.
|
|||
|
||||
**NEW**
|
||||
|
||||

|
||||

|
||||
|
||||
## [5] Page header renderer on the Trending page
|
||||
|
||||
- **Encountered on:** 1.05.2023
|
||||
- **Impact:** 🟢 Low
|
||||
- **Endpoint:** browse (trending videos)
|
||||
- **Status:** Stabilized
|
||||
|
||||
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
|
||||
- **Impact:** 🟡 Medium
|
||||
- **Endpoint:** browse (music artist)
|
||||
- **Status:** Stabilized
|
||||
|
||||
YouTube merged the 2 sections for singles and albums on artist pages together. Now there
|
||||
is only a _Top Releases_ section.
|
||||
YouTube merged the 2 sections for singles and albums on artist pages together. Now
|
||||
there is only a *Top Releases* section.
|
||||
|
||||
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
|
||||
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
|
||||
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**
|
||||
|
||||

|
||||

|
||||
|
||||
**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.
|
||||
|
||||
use std::{
|
||||
fs::File,
|
||||
io::Write,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tracing::error;
|
||||
use log::error;
|
||||
|
||||
pub(crate) const DEFAULT_CACHE_FILE: &str = "rustypipe_cache.json";
|
||||
|
||||
|
@ -69,21 +68,7 @@ impl Default for FileStorage {
|
|||
|
||||
impl CacheStorage for FileStorage {
|
||||
fn write(&self, data: &str) {
|
||||
fn _write(path: &Path, data: &str) -> Result<(), std::io::Error> {
|
||||
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| {
|
||||
fs::write(&self.path, data).unwrap_or_else(|e| {
|
||||
error!(
|
||||
"Could not write cache to file `{}`. Error: {}",
|
||||
self.path.to_string_lossy(),
|
||||
|
@ -97,7 +82,7 @@ impl CacheStorage for FileStorage {
|
|||
return None;
|
||||
}
|
||||
|
||||
match std::fs::read_to_string(&self.path) {
|
||||
match fs::read_to_string(&self.path) {
|
||||
Ok(data) => Some(data),
|
||||
Err(e) => {
|
||||
error!(
|
||||
|
|
|
@ -1,28 +1,23 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
client::response::YouTubeListItem,
|
||||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
Channel, ChannelInfo, PlaylistItem, Verification, VideoItem,
|
||||
Channel, ChannelInfo, PlaylistItem, VideoItem, YouTubeItem,
|
||||
},
|
||||
param::{ChannelOrder, ChannelVideoTab, Language},
|
||||
serializer::{text::TextComponent, MapResult},
|
||||
util::{self, timeago, ProtoBuilder},
|
||||
serializer::MapResult,
|
||||
util::{self, ProtoBuilder},
|
||||
};
|
||||
|
||||
use super::{
|
||||
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
|
||||
};
|
||||
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QChannel<'a> {
|
||||
context: YTContext<'a>,
|
||||
browse_id: &'a str,
|
||||
params: ChannelTab,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
@ -39,6 +34,8 @@ enum ChannelTab {
|
|||
Live,
|
||||
#[serde(rename = "EglwbGF5bGlzdHMgAQ%3D%3D")]
|
||||
Playlists,
|
||||
#[serde(rename = "EgVhYm91dPIGBAoCEgA%3D")]
|
||||
Info,
|
||||
#[serde(rename = "EgZzZWFyY2jyBgQKAloA")]
|
||||
Search,
|
||||
}
|
||||
|
@ -62,7 +59,9 @@ impl RustyPipeQuery {
|
|||
operation: &str,
|
||||
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
||||
let channel_id = channel_id.as_ref();
|
||||
let context = self.get_context(ClientType::Desktop, true, None).await;
|
||||
let request_body = QChannel {
|
||||
context,
|
||||
browse_id: channel_id,
|
||||
params,
|
||||
query,
|
||||
|
@ -79,8 +78,7 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Get the videos from a YouTube channel
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn channel_videos<S: AsRef<str> + Debug>(
|
||||
pub async fn channel_videos<S: AsRef<str>>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
||||
|
@ -91,8 +89,7 @@ impl RustyPipeQuery {
|
|||
/// Get a ordered list of videos from a YouTube channel
|
||||
///
|
||||
/// This function does not return channel metadata.
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn channel_videos_order<S: AsRef<str> + Debug>(
|
||||
pub async fn channel_videos_order<S: AsRef<str>>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
order: ChannelOrder,
|
||||
|
@ -102,8 +99,7 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// 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> + Debug>(
|
||||
pub async fn channel_videos_tab<S: AsRef<str>>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
tab: ChannelVideoTab,
|
||||
|
@ -115,24 +111,27 @@ impl RustyPipeQuery {
|
|||
/// Get a ordered list of videos from the given tab (Shorts, Livestreams) of a YouTube channel
|
||||
///
|
||||
/// This function does not return channel metadata.
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn channel_videos_tab_order<S: AsRef<str> + Debug>(
|
||||
pub async fn channel_videos_tab_order<S: AsRef<str>>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
tab: ChannelVideoTab,
|
||||
order: ChannelOrder,
|
||||
) -> Result<Paginator<VideoItem>, Error> {
|
||||
let visitor_data = match tab {
|
||||
ChannelVideoTab::Shorts => Some(self.get_visitor_data().await?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
self.continuation(
|
||||
order_ctoken(channel_id.as_ref(), tab, order, &random_target()),
|
||||
order_ctoken(channel_id.as_ref(), tab, order),
|
||||
ContinuationEndpoint::Browse,
|
||||
None,
|
||||
visitor_data.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Search the videos of a channel
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn channel_search<S: AsRef<str> + Debug, S2: AsRef<str> + Debug>(
|
||||
pub async fn channel_search<S: AsRef<str>, S2: AsRef<str>>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
query: S2,
|
||||
|
@ -147,13 +146,14 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Get the playlists of a channel
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn channel_playlists<S: AsRef<str> + Debug>(
|
||||
pub async fn channel_playlists<S: AsRef<str>>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
) -> Result<Channel<Paginator<PlaylistItem>>, Error> {
|
||||
let channel_id = channel_id.as_ref();
|
||||
let context = self.get_context(ClientType::Desktop, true, None).await;
|
||||
let request_body = QChannel {
|
||||
context,
|
||||
browse_id: channel_id,
|
||||
params: ChannelTab::Playlists,
|
||||
query: None,
|
||||
|
@ -170,26 +170,25 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Get additional metadata from the *About* tab of a channel
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn channel_info<S: AsRef<str> + Debug>(
|
||||
pub async fn channel_info<S: AsRef<str>>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
) -> Result<ChannelInfo, Error> {
|
||||
) -> Result<Channel<ChannelInfo>, Error> {
|
||||
let channel_id = channel_id.as_ref();
|
||||
let request_body = QContinuation {
|
||||
continuation: &channel_info_ctoken(channel_id, &random_target()),
|
||||
let context = self.get_context(ClientType::Desktop, true, None).await;
|
||||
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,
|
||||
"channel_info",
|
||||
channel_id,
|
||||
"browse",
|
||||
&request_body,
|
||||
MapRespOptions {
|
||||
unlocalized: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
@ -198,28 +197,27 @@ impl RustyPipeQuery {
|
|||
impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> {
|
||||
let content = map_channel_content(ctx.id, self.contents, self.alerts)?;
|
||||
let visitor_data = self
|
||||
.response_context
|
||||
.visitor_data
|
||||
.or_else(|| ctx.visitor_data.map(str::to_owned));
|
||||
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: visitor_data.clone(),
|
||||
visitor_data: self.response_context.visitor_data.clone(),
|
||||
has_shorts: content.has_shorts,
|
||||
has_live: content.has_live,
|
||||
},
|
||||
ctx,
|
||||
id,
|
||||
lang,
|
||||
)?;
|
||||
|
||||
let mut mapper = response::YouTubeListMapper::<VideoItem>::with_channel(
|
||||
ctx.lang,
|
||||
lang,
|
||||
&channel_data.c,
|
||||
channel_data.warnings,
|
||||
);
|
||||
|
@ -228,9 +226,8 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
|
|||
None,
|
||||
mapper.items,
|
||||
mapper.ctoken,
|
||||
visitor_data,
|
||||
ContinuationEndpoint::Browse,
|
||||
false,
|
||||
self.response_context.visitor_data,
|
||||
crate::model::paginator::ContinuationEndpoint::Browse,
|
||||
);
|
||||
|
||||
Ok(MapResult {
|
||||
|
@ -243,28 +240,27 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
|
|||
impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<Channel<Paginator<PlaylistItem>>>, ExtractionError> {
|
||||
let content = map_channel_content(ctx.id, self.contents, self.alerts)?;
|
||||
let visitor_data = self
|
||||
.response_context
|
||||
.visitor_data
|
||||
.or_else(|| ctx.visitor_data.map(str::to_owned));
|
||||
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,
|
||||
visitor_data: self.response_context.visitor_data,
|
||||
has_shorts: content.has_shorts,
|
||||
has_live: content.has_live,
|
||||
},
|
||||
ctx,
|
||||
id,
|
||||
lang,
|
||||
)?;
|
||||
|
||||
let mut mapper = response::YouTubeListMapper::<PlaylistItem>::with_channel(
|
||||
ctx.lang,
|
||||
lang,
|
||||
&channel_data.c,
|
||||
channel_data.warnings,
|
||||
);
|
||||
|
@ -278,77 +274,59 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
|
|||
}
|
||||
}
|
||||
|
||||
impl MapResponse<ChannelInfo> for response::ChannelAbout {
|
||||
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<ChannelInfo>, ExtractionError> {
|
||||
// Channel info is always fetched in English. There is no localized data
|
||||
// and it allows parsing the country name.
|
||||
let lang = Language::En;
|
||||
impl MapResponse<Channel<ChannelInfo>> for response::Channel {
|
||||
fn map_response(
|
||||
self,
|
||||
id: &str,
|
||||
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 {
|
||||
response::ChannelAbout::ReceivedEndpoints {
|
||||
on_response_received_endpoints,
|
||||
} => 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 mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
|
||||
mapper.map_response(content.content);
|
||||
let mut warnings = mapper.warnings;
|
||||
|
||||
let cinfo = mapper.channel_info.unwrap_or_else(|| {
|
||||
warnings.push("no aboutFullMetadata".to_owned());
|
||||
ChannelInfo {
|
||||
create_date: None,
|
||||
view_count: None,
|
||||
links: Vec::new(),
|
||||
}
|
||||
};
|
||||
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
|
||||
.links
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let lv = l.channel_external_link_view_model;
|
||||
if let TextComponent::Web { url, .. } = lv.link {
|
||||
Some((String::from(lv.title), util::sanitize_yt_url(&url)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
});
|
||||
|
||||
Ok(MapResult {
|
||||
c: ChannelInfo {
|
||||
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,
|
||||
},
|
||||
c: combine_channel_data(channel_data.c, cinfo),
|
||||
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 {
|
||||
header: Option<response::channel::Header>,
|
||||
metadata: Option<response::channel::Metadata>,
|
||||
|
@ -360,41 +338,36 @@ struct MapChannelData {
|
|||
|
||||
fn map_channel(
|
||||
d: MapChannelData,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: Language,
|
||||
) -> Result<MapResult<Channel<()>>, ExtractionError> {
|
||||
let header = d.header.ok_or_else(|| ExtractionError::NotFound {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
msg: "no header".into(),
|
||||
})?;
|
||||
let metadata = d
|
||||
.metadata
|
||||
.ok_or_else(|| ExtractionError::NotFound {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
msg: "no metadata".into(),
|
||||
})?
|
||||
.channel_metadata_renderer;
|
||||
let microformat = d.microformat.ok_or_else(|| ExtractionError::NotFound {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
msg: "no microformat".into(),
|
||||
})?;
|
||||
|
||||
if metadata.external_id != ctx.id {
|
||||
if metadata.external_id != id {
|
||||
return Err(ExtractionError::WrongResult(format!(
|
||||
"got wrong channel id {}, expected {}",
|
||||
metadata.external_id, ctx.id
|
||||
metadata.external_id, id
|
||||
)));
|
||||
}
|
||||
|
||||
let handle = metadata
|
||||
let vanity_url = metadata
|
||||
.vanity_channel_url
|
||||
.as_ref()
|
||||
.and_then(|url| Url::parse(url).ok())
|
||||
.and_then(|url| {
|
||||
url.path()
|
||||
.strip_prefix('/')
|
||||
.filter(|handle| util::CHANNEL_HANDLE_REGEX.is_match(handle))
|
||||
.map(str::to_owned)
|
||||
});
|
||||
.and_then(|url| map_vanity_url(url, id));
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
Ok(MapResult {
|
||||
|
@ -402,16 +375,17 @@ fn map_channel(
|
|||
response::channel::Header::C4TabbedHeaderRenderer(header) => Channel {
|
||||
id: metadata.external_id,
|
||||
name: metadata.title,
|
||||
handle,
|
||||
subscriber_count: header.subscriber_count_text.and_then(|txt| {
|
||||
util::parse_large_numstr_or_warn(&txt, ctx.lang, &mut warnings)
|
||||
}),
|
||||
video_count: None,
|
||||
subscriber_count: header
|
||||
.subscriber_count_text
|
||||
.and_then(|txt| util::parse_large_numstr_or_warn(&txt, lang, &mut warnings)),
|
||||
avatar: header.avatar.into(),
|
||||
verification: header.badges.into(),
|
||||
description: metadata.description,
|
||||
tags: microformat.microformat_data_renderer.tags,
|
||||
vanity_url,
|
||||
banner: header.banner.into(),
|
||||
mobile_banner: header.mobile_banner.into(),
|
||||
tv_banner: header.tv_banner.into(),
|
||||
has_shorts: d.has_shorts,
|
||||
has_live: d.has_live,
|
||||
visitor_data: d.visitor_data,
|
||||
|
@ -432,68 +406,19 @@ fn map_channel(
|
|||
Channel {
|
||||
id: metadata.external_id,
|
||||
name: metadata.title,
|
||||
handle,
|
||||
subscriber_count: hdata.as_ref().and_then(|hdata| {
|
||||
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(),
|
||||
// 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,
|
||||
description: metadata.description,
|
||||
tags: microformat.microformat_data_renderer.tags,
|
||||
vanity_url,
|
||||
banner: Vec::new(),
|
||||
has_shorts: d.has_shorts,
|
||||
has_live: d.has_live,
|
||||
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(),
|
||||
mobile_banner: Vec::new(),
|
||||
tv_banner: Vec::new(),
|
||||
has_shorts: d.has_shorts,
|
||||
has_live: d.has_live,
|
||||
visitor_data: d.visitor_data,
|
||||
|
@ -519,6 +444,13 @@ fn map_channel_content(
|
|||
match contents {
|
||||
Some(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,
|
||||
expect: &str| {
|
||||
endpoint
|
||||
|
@ -533,46 +465,24 @@ fn map_channel_content(
|
|||
let mut featured_tab = false;
|
||||
|
||||
for tab in &tabs {
|
||||
if let Some(endpoint) = &tab.tab_renderer.endpoint {
|
||||
if cmp_url_suffix(endpoint, "/featured")
|
||||
&& (tab.tab_renderer.content.section_list_renderer.is_some()
|
||||
|| tab.tab_renderer.content.rich_grid_renderer.is_some())
|
||||
{
|
||||
featured_tab = true;
|
||||
} else if cmp_url_suffix(endpoint, "/shorts") {
|
||||
has_shorts = true;
|
||||
} else if cmp_url_suffix(endpoint, "/streams") {
|
||||
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}"),
|
||||
});
|
||||
}
|
||||
if cmp_url_suffix(&tab.tab_renderer.endpoint, "/featured")
|
||||
&& (tab.tab_renderer.content.section_list_renderer.is_some()
|
||||
|| tab.tab_renderer.content.rich_grid_renderer.is_some())
|
||||
{
|
||||
featured_tab = true;
|
||||
} else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/shorts") {
|
||||
has_shorts = true;
|
||||
} else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/streams") {
|
||||
has_live = true;
|
||||
}
|
||||
}
|
||||
|
||||
let channel_content = tabs
|
||||
.into_iter()
|
||||
.filter(|t| t.tab_renderer.endpoint.is_some())
|
||||
.find_map(|tab| {
|
||||
tab.tab_renderer
|
||||
.content
|
||||
.rich_grid_renderer
|
||||
.or(tab.tab_renderer.content.section_list_renderer)
|
||||
});
|
||||
let channel_content = tabs.into_iter().find_map(|tab| {
|
||||
tab.tab_renderer
|
||||
.content
|
||||
.rich_grid_renderer
|
||||
.or(tab.tab_renderer.content.section_list_renderer)
|
||||
});
|
||||
|
||||
// YouTube may show the "Featured" tab if the requested tab is empty/does not exist
|
||||
let content = if featured_tab {
|
||||
|
@ -581,10 +491,9 @@ fn map_channel_content(
|
|||
match channel_content {
|
||||
Some(list) => list.contents,
|
||||
None => {
|
||||
return Err(ExtractionError::NotFound {
|
||||
id: id.to_owned(),
|
||||
msg: "no tabs".into(),
|
||||
});
|
||||
return Err(ExtractionError::InvalidData(
|
||||
"could not extract content".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -603,14 +512,15 @@ fn combine_channel_data<T>(channel_data: Channel<()>, content: T) -> Channel<T>
|
|||
Channel {
|
||||
id: channel_data.id,
|
||||
name: channel_data.name,
|
||||
handle: channel_data.handle,
|
||||
subscriber_count: channel_data.subscriber_count,
|
||||
video_count: channel_data.video_count,
|
||||
avatar: channel_data.avatar,
|
||||
verification: channel_data.verification,
|
||||
description: channel_data.description,
|
||||
tags: channel_data.tags,
|
||||
vanity_url: channel_data.vanity_url,
|
||||
banner: channel_data.banner,
|
||||
mobile_banner: channel_data.mobile_banner,
|
||||
tv_banner: channel_data.tv_banner,
|
||||
has_shorts: channel_data.has_shorts,
|
||||
has_live: channel_data.has_live,
|
||||
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
|
||||
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,
|
||||
tab: ChannelVideoTab,
|
||||
order: ChannelOrder,
|
||||
|
@ -627,33 +548,7 @@ fn order_ctoken(
|
|||
) -> String {
|
||||
let mut pb_tab = ProtoBuilder::new();
|
||||
pb_tab.string(2, target_id);
|
||||
|
||||
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),
|
||||
},
|
||||
}
|
||||
pb_tab.varint(3, order as u64);
|
||||
|
||||
let mut pb_3 = ProtoBuilder::new();
|
||||
pb_3.embedded(tab.order_ctoken_id(), pb_tab);
|
||||
|
@ -674,32 +569,6 @@ fn order_ctoken(
|
|||
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)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader};
|
||||
|
@ -708,15 +577,14 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use crate::{
|
||||
client::{response, MapRespCtx, MapResponse},
|
||||
error::{ExtractionError, UnavailabilityReason},
|
||||
client::{response, MapResponse},
|
||||
model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem},
|
||||
param::{ChannelOrder, ChannelVideoTab},
|
||||
param::{ChannelOrder, ChannelVideoTab, Language},
|
||||
serializer::MapResult,
|
||||
util::tests::TESTFILES,
|
||||
};
|
||||
|
||||
use super::{channel_info_ctoken, order_ctoken};
|
||||
use super::_order_ctoken;
|
||||
|
||||
#[rstest]
|
||||
#[case::base("videos_base", "UC2DjFE7Xf11URZqWBigcVOQ")]
|
||||
|
@ -727,12 +595,9 @@ mod tests {
|
|||
#[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")]
|
||||
#[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")]
|
||||
#[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")]
|
||||
#[case::coachella("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
|
||||
#[case::richgrid2("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
|
||||
#[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")]
|
||||
#[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) {
|
||||
let json_path = path!(*TESTFILES / "channel" / format!("channel_{name}.json"));
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
@ -740,7 +605,7 @@ mod tests {
|
|||
let channel: response::Channel =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<Channel<Paginator<VideoItem>>> =
|
||||
channel.map_response(&MapRespCtx::test(id)).unwrap();
|
||||
channel.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
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]
|
||||
#[case::base("base")]
|
||||
#[case::lockup("20241109_lockup")]
|
||||
fn map_channel_playlists(#[case] name: &str) {
|
||||
let json_path = path!(*TESTFILES / "channel" / format!("channel_playlists_{name}.json"));
|
||||
fn map_channel_playlists() {
|
||||
let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json");
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let channel: response::Channel =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<Channel<Paginator<PlaylistItem>>> = channel
|
||||
.map_response(&MapRespCtx::test("UC2DjFE7Xf11URZqWBigcVOQ"))
|
||||
.map_response("UC2DjFE7Xf11URZqWBigcVOQ", Language::En, None)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
|
@ -794,7 +640,7 @@ mod tests {
|
|||
"deserialization/mapping 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]
|
||||
|
@ -802,10 +648,10 @@ mod tests {
|
|||
let json_path = path!(*TESTFILES / "channel" / "channel_info.json");
|
||||
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();
|
||||
let map_res: MapResult<ChannelInfo> = channel
|
||||
.map_response(&MapRespCtx::test("UC2DjFE7Xf11U-RZqWBigcVOQ"))
|
||||
let map_res: MapResult<Channel<ChannelInfo>> = channel
|
||||
.map_response("UC2DjFE7Xf11URZqWBigcVOQ", Language::En, None)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
|
@ -817,39 +663,31 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn t_order_ctoken() {
|
||||
fn order_ctoken() {
|
||||
let channel_id = "UCXuqSBlHAE6Xw-yeJA0Tunw";
|
||||
|
||||
let videos_popular_token = order_ctoken(
|
||||
let videos_popular_token = _order_ctoken(
|
||||
channel_id,
|
||||
ChannelVideoTab::Videos,
|
||||
ChannelOrder::Popular,
|
||||
"\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,
|
||||
ChannelVideoTab::Shorts,
|
||||
ChannelOrder::Popular,
|
||||
"\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,
|
||||
ChannelVideoTab::Live,
|
||||
ChannelOrder::Popular,
|
||||
"\n$64693069-0000-2a1e-8c7d-582429bd5ba8",
|
||||
);
|
||||
assert_eq!(live_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXh5S2hJbUNpUTJORFk1TXpBMk9TMHdNREF3TFRKaE1XVXRPR00zWkMwMU9ESTBNamxpWkRWaVlUZ29EZyUzRCUzRA%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");
|
||||
assert_eq!(live_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXh5S2hJbUNpUTJORFk1TXpBMk9TMHdNREF3TFRKaE1XVXRPR00zWkMwMU9ESTBNamxpWkRWaVlUZ1lBZyUzRCUzRA%3D%3D");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
use std::fmt::Debug;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
error::{Error, ExtractionError},
|
||||
model::ChannelRss,
|
||||
report::Report,
|
||||
util,
|
||||
report::{Report, RustyPipeInfo},
|
||||
};
|
||||
|
||||
use super::{response, RustyPipeQuery};
|
||||
|
@ -18,11 +17,7 @@ impl RustyPipeQuery {
|
|||
/// 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.
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn channel_rss<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
channel_id: S,
|
||||
) -> Result<ChannelRss, Error> {
|
||||
pub async fn channel_rss<S: AsRef<str>>(&self, channel_id: S) -> Result<ChannelRss, Error> {
|
||||
let channel_id = channel_id.as_ref();
|
||||
let url = format!("https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}");
|
||||
let xml = self
|
||||
|
@ -37,15 +32,12 @@ impl RustyPipeQuery {
|
|||
_ => e,
|
||||
})?;
|
||||
|
||||
match quick_xml::de::from_str::<response::ChannelRss>(&xml)
|
||||
.map_err(|e| ExtractionError::InvalidData(e.to_string().into()))
|
||||
.and_then(|feed| feed.map_response(channel_id))
|
||||
{
|
||||
Ok(res) => Ok(res),
|
||||
match quick_xml::de::from_str::<response::ChannelRss>(&xml) {
|
||||
Ok(feed) => Ok(feed.into()),
|
||||
Err(e) => {
|
||||
if let Some(reporter) = &self.client.inner.reporter {
|
||||
let report = Report {
|
||||
info: self.rp_info(),
|
||||
info: RustyPipeInfo::default(),
|
||||
level: crate::report::Level::ERR,
|
||||
operation: "channel_rss",
|
||||
error: Some(e.to_string()),
|
||||
|
@ -54,103 +46,47 @@ impl RustyPipeQuery {
|
|||
http_request: crate::report::HTTPRequest {
|
||||
url: &url,
|
||||
method: "GET",
|
||||
req_header: BTreeMap::new(),
|
||||
req_body: String::new(),
|
||||
status: 200,
|
||||
req_header: None,
|
||||
req_body: None,
|
||||
resp_body: xml,
|
||||
},
|
||||
};
|
||||
|
||||
reporter.report(&report);
|
||||
}
|
||||
Err(Error::Extraction(e))
|
||||
|
||||
Err(
|
||||
ExtractionError::InvalidData(format!("could not deserialize xml: {e}").into())
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl response::ChannelRss {
|
||||
fn map_response(self, id: &str) -> Result<ChannelRss, ExtractionError> {
|
||||
let channel_id = if self.channel_id.is_empty() {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::base("base", "UCHnyfMqiRRG1u-2MsSQLbXA")]
|
||||
#[case::no_likes("no_likes", "UCdfxp4cUWsWryZOy-o427dw")]
|
||||
#[case::no_channel_id("no_channel_id", "UCHnyfMqiRRG1u-2MsSQLbXA")]
|
||||
#[case::trimmed_channel_id("trimmed_channel_id", "UCHnyfMqiRRG1u-2MsSQLbXA")]
|
||||
fn map_channel_rss(#[case] name: &str, #[case] id: &str) {
|
||||
#[case::base("base")]
|
||||
#[case::no_likes("no_likes")]
|
||||
#[case::no_channel_id("no_channel_id")]
|
||||
fn map_channel_rss(#[case] name: &str) {
|
||||
let xml_path = path!(*TESTFILES / "channel_rss" / format!("{}.xml", name));
|
||||
let xml_file = File::open(xml_path).unwrap();
|
||||
|
||||
let feed: response::ChannelRss =
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
2030
src/client/mod.rs
|
@ -2,26 +2,17 @@ use std::borrow::Cow;
|
|||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
client::{
|
||||
response::{music_item::map_album_type, url_endpoint::NavigationEndpoint},
|
||||
MapRespOptions, QContinuation,
|
||||
},
|
||||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::Paginator, traits::FromYtItem, AlbumItem, AlbumType, ArtistId, MusicArtist,
|
||||
MusicItem,
|
||||
},
|
||||
param::{AlbumFilter, AlbumOrder},
|
||||
model::{AlbumItem, ArtistId, MusicArtist},
|
||||
serializer::MapResult,
|
||||
util::{self, ProtoBuilder},
|
||||
util,
|
||||
};
|
||||
|
||||
use super::{
|
||||
response::{self, music_item::MusicListMapper, url_endpoint::PageType},
|
||||
ClientType, MapRespCtx, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery,
|
||||
ClientType, MapResponse, QBrowse, RustyPipeQuery,
|
||||
};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
|
@ -37,7 +28,7 @@ impl RustyPipeQuery {
|
|||
let res = self._music_artist(artist_id, all_albums).await;
|
||||
|
||||
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
|
||||
} else {
|
||||
res
|
||||
|
@ -45,7 +36,9 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
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 {
|
||||
context,
|
||||
browse_id: artist_id,
|
||||
};
|
||||
|
||||
|
@ -61,9 +54,7 @@ impl RustyPipeQuery {
|
|||
.await?;
|
||||
|
||||
if can_fetch_more {
|
||||
artist.albums = self
|
||||
.music_artist_albums(artist_id, None, Some(AlbumOrder::Recency))
|
||||
.await?;
|
||||
artist.albums = self.music_artist_albums(artist_id).await?;
|
||||
}
|
||||
|
||||
Ok(artist)
|
||||
|
@ -80,59 +71,32 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Get a list of all albums of a YouTube Music artist
|
||||
pub async fn music_artist_albums(
|
||||
&self,
|
||||
artist_id: &str,
|
||||
filter: Option<AlbumFilter>,
|
||||
order: Option<AlbumOrder>,
|
||||
) -> Result<Vec<AlbumItem>, Error> {
|
||||
let request_body = QBrowseParams {
|
||||
pub async fn music_artist_albums(&self, artist_id: &str) -> Result<Vec<AlbumItem>, Error> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: &format!("{}{}", util::ARTIST_DISCOGRAPHY_PREFIX, artist_id),
|
||||
params: &albums_param(filter, order),
|
||||
};
|
||||
|
||||
let first_page = self
|
||||
.execute_request::<response::MusicArtistAlbums, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_artist_albums",
|
||||
artist_id,
|
||||
"browse",
|
||||
&request_body,
|
||||
)
|
||||
.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)
|
||||
self.execute_request::<response::MusicArtistAlbums, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_artist_albums",
|
||||
artist_id,
|
||||
"browse",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<MusicArtist> for response::MusicArtist {
|
||||
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicArtist>, ExtractionError> {
|
||||
let mapped = map_artist_page(self, ctx, false)?;
|
||||
fn map_response(
|
||||
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 {
|
||||
c: mapped.c.0,
|
||||
warnings: mapped.warnings,
|
||||
|
@ -143,15 +107,18 @@ impl MapResponse<MusicArtist> for response::MusicArtist {
|
|||
impl MapResponse<(MusicArtist, bool)> for response::MusicArtist {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> {
|
||||
map_artist_page(self, ctx, true)
|
||||
map_artist_page(self, id, lang, true)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_artist_page(
|
||||
res: response::MusicArtist,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: crate::param::Language,
|
||||
skip_extendables: bool,
|
||||
) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> {
|
||||
// dbg!(&res);
|
||||
|
@ -167,7 +134,7 @@ fn map_artist_page(
|
|||
.and_then(|pb| util::string_from_pb(pb, 3));
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -179,13 +146,14 @@ fn map_artist_page(
|
|||
.contents
|
||||
.into_iter()
|
||||
.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();
|
||||
|
||||
let mut mapper = MusicListMapper::with_artist(
|
||||
ctx.lang,
|
||||
lang,
|
||||
ArtistId {
|
||||
id: Some(ctx.id.to_owned()),
|
||||
id: Some(id.to_owned()),
|
||||
name: header.title.clone(),
|
||||
},
|
||||
);
|
||||
|
@ -210,56 +178,50 @@ fn map_artist_page(
|
|||
}
|
||||
}
|
||||
}
|
||||
mapper.album_type = AlbumType::Single;
|
||||
|
||||
mapper.map_response(shelf.contents);
|
||||
}
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
let mut extendable_albums = false;
|
||||
mapper.album_type = AlbumType::Single;
|
||||
if let Some(h) = shelf.header {
|
||||
if let Some(button) = h
|
||||
.music_carousel_shelf_basic_header_renderer
|
||||
.more_content_button
|
||||
{
|
||||
if let NavigationEndpoint::Browse {
|
||||
browse_endpoint, ..
|
||||
} = button.button_renderer.navigation_endpoint
|
||||
if let Some(bep) =
|
||||
button.button_renderer.navigation_endpoint.browse_endpoint
|
||||
{
|
||||
// Music videos
|
||||
if browse_endpoint
|
||||
.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() {
|
||||
videos_playlist_id = Some(browse_endpoint.browse_id);
|
||||
}
|
||||
} else if browse_endpoint
|
||||
.browse_id
|
||||
.starts_with(util::ARTIST_DISCOGRAPHY_PREFIX)
|
||||
{
|
||||
can_fetch_more = true;
|
||||
extendable_albums = true;
|
||||
} else {
|
||||
// Peek at the first item to determine type
|
||||
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(cfg) = bep.browse_endpoint_context_supported_configs {
|
||||
match cfg.browse_endpoint_context_music_config.page_type {
|
||||
// Music videos
|
||||
PageType::Playlist => {
|
||||
if videos_playlist_id.is_none() {
|
||||
videos_playlist_id = Some(bep.browse_id);
|
||||
}
|
||||
}
|
||||
// Albums
|
||||
PageType::ArtistDiscography => {
|
||||
can_fetch_more = true;
|
||||
extendable_albums = true;
|
||||
}
|
||||
// Albums or playlists
|
||||
PageType::Artist => {
|
||||
// Peek at the first item to determine type
|
||||
if let Some(response::music_item::MusicResponseItem::MusicTwoRowItemRenderer(item)) = shelf.contents.c.first() {
|
||||
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;
|
||||
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 {
|
||||
|
@ -270,6 +232,7 @@ fn map_artist_page(
|
|||
}
|
||||
}
|
||||
|
||||
mapper.check_unknown()?;
|
||||
let mut mapped = mapper.group_items();
|
||||
|
||||
static WIKIPEDIA_REGEX: Lazy<Regex> =
|
||||
|
@ -288,18 +251,16 @@ fn map_artist_page(
|
|||
});
|
||||
|
||||
let radio_id = header.start_radio_button.and_then(|b| {
|
||||
if let NavigationEndpoint::Watch { watch_endpoint } = b.button_renderer.navigation_endpoint
|
||||
{
|
||||
watch_endpoint.playlist_id
|
||||
} else {
|
||||
None
|
||||
}
|
||||
b.button_renderer
|
||||
.navigation_endpoint
|
||||
.watch_endpoint
|
||||
.and_then(|w| w.playlist_id)
|
||||
});
|
||||
|
||||
Ok(MapResult {
|
||||
c: (
|
||||
MusicArtist {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
name: header.title,
|
||||
header_image: header.thumbnail.into(),
|
||||
description: header.description,
|
||||
|
@ -307,7 +268,7 @@ fn map_artist_page(
|
|||
subscriber_count: header.subscription_button.and_then(|btn| {
|
||||
util::parse_large_numstr_or_warn(
|
||||
&btn.subscribe_button_renderer.subscriber_count_text,
|
||||
ctx.lang,
|
||||
lang,
|
||||
&mut mapped.warnings,
|
||||
)
|
||||
}),
|
||||
|
@ -325,26 +286,17 @@ fn map_artist_page(
|
|||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FirstAlbumPage {
|
||||
albums: Vec<AlbumItem>,
|
||||
ctoken: Option<String>,
|
||||
artist: ArtistId,
|
||||
visitor_data: Option<String>,
|
||||
}
|
||||
|
||||
impl MapResponse<FirstAlbumPage> for response::MusicArtistAlbums {
|
||||
impl MapResponse<Vec<AlbumItem>> for response::MusicArtistAlbums {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<FirstAlbumPage>, ExtractionError> {
|
||||
id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<Vec<AlbumItem>>, ExtractionError> {
|
||||
// dbg!(&self);
|
||||
|
||||
let Some(header) = self.header else {
|
||||
return Err(ExtractionError::NotFound {
|
||||
id: ctx.id.into(),
|
||||
msg: "no header".into(),
|
||||
});
|
||||
return Err(ExtractionError::NotFound { id: id.into(), msg: "no header".into() });
|
||||
};
|
||||
|
||||
let grids = self
|
||||
|
@ -359,56 +311,28 @@ impl MapResponse<FirstAlbumPage> for response::MusicArtistAlbums {
|
|||
.section_list_renderer
|
||||
.contents;
|
||||
|
||||
let artist_id = ArtistId {
|
||||
id: Some(ctx.id.to_owned()),
|
||||
name: header.music_header_renderer.title,
|
||||
};
|
||||
let mut mapper = MusicListMapper::with_artist(ctx.lang, artist_id.clone());
|
||||
let mut ctoken = None;
|
||||
let mut mapper = MusicListMapper::with_artist(
|
||||
lang,
|
||||
ArtistId {
|
||||
id: Some(id.to_owned()),
|
||||
name: header.music_header_renderer.title,
|
||||
},
|
||||
);
|
||||
|
||||
for grid in grids {
|
||||
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();
|
||||
|
||||
Ok(MapResult {
|
||||
c: FirstAlbumPage {
|
||||
albums: mapped.c.albums,
|
||||
ctoken,
|
||||
artist: artist_id,
|
||||
visitor_data: ctx.visitor_data.map(str::to_owned),
|
||||
},
|
||||
c: mapped.c.albums,
|
||||
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)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader};
|
||||
|
@ -416,7 +340,7 @@ mod tests {
|
|||
use path_macro::path;
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::util::tests::TESTFILES;
|
||||
use crate::{param::Language, util::tests::TESTFILES};
|
||||
|
||||
use super::*;
|
||||
|
||||
|
@ -425,7 +349,6 @@ mod tests {
|
|||
#[case::only_singles("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw")]
|
||||
#[case::no_artist("no_artist", "UCh8gHdtzO2tXd593_bjErWg")]
|
||||
#[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) {
|
||||
let json_path = path!(*TESTFILES / "music_artist" / format!("artist_{name}.json"));
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
@ -439,7 +362,7 @@ mod tests {
|
|||
let resp: response::MusicArtist =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
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;
|
||||
|
||||
assert!(
|
||||
|
@ -449,42 +372,19 @@ mod tests {
|
|||
);
|
||||
assert_eq!(can_fetch_more, album_page_path.is_some());
|
||||
|
||||
// Album overview
|
||||
if let Some(album_page_path) = album_page_path {
|
||||
let json_file = File::open(album_page_path).unwrap();
|
||||
let resp: response::MusicArtistAlbums =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<FirstAlbumPage> =
|
||||
resp.map_response(&MapRespCtx::test(id)).unwrap();
|
||||
let mut map_res: MapResult<Vec<AlbumItem>> =
|
||||
resp.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
artist.albums = map_res.c.albums;
|
||||
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
artist.albums.append(&mut map_res.c);
|
||||
}
|
||||
|
||||
insta::assert_ron_snapshot!(format!("map_music_artist_{name}"), artist);
|
||||
|
@ -498,7 +398,7 @@ mod tests {
|
|||
let artist: response::MusicArtist =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicArtist> = artist
|
||||
.map_response(&MapRespCtx::test("UClmXPfaYhXOYsNn_QUyheWQ"))
|
||||
.map_response("UClmXPfaYhXOYsNn_QUyheWQ", Language::En, None)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
|
@ -517,12 +417,12 @@ mod tests {
|
|||
let artist: response::MusicArtist =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let res: Result<MapResult<MusicArtist>, ExtractionError> =
|
||||
artist.map_response(&MapRespCtx::test("UCLkAepWjdylmXSltofFvsYQ"));
|
||||
artist.map_response("UCLkAepWjdylmXSltofFvsYQ", Language::En, None);
|
||||
let e = res.unwrap_err();
|
||||
|
||||
match e {
|
||||
ExtractionError::Redirect(id) => {
|
||||
assert_eq!(id, "UCOR4_bSVIXPsGa4BbCSt60Q");
|
||||
assert_eq!(id, "UCOR4_bSVIXPsGa4BbCSt60Q")
|
||||
}
|
||||
_ => panic!("error: {e}"),
|
||||
}
|
||||
|
|
|
@ -11,12 +11,13 @@ use crate::{
|
|||
|
||||
use super::{
|
||||
response::{self, music_item::MusicListMapper, url_endpoint::MusicPageType},
|
||||
ClientType, MapRespCtx, MapResponse, RustyPipeQuery,
|
||||
ClientType, MapResponse, RustyPipeQuery, YTContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QCharts<'a> {
|
||||
context: YTContext<'a>,
|
||||
browse_id: &'a str,
|
||||
params: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
@ -31,9 +32,10 @@ struct FormData {
|
|||
|
||||
impl RustyPipeQuery {
|
||||
/// 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> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QCharts {
|
||||
context,
|
||||
browse_id: "FEmusic_charts",
|
||||
params: "sgYPRkVtdXNpY19leHBsb3Jl",
|
||||
form_data: country.map(|c| FormData {
|
||||
|
@ -53,7 +55,12 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
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
|
||||
.framework_updates
|
||||
.map(|fwu| {
|
||||
|
@ -68,9 +75,9 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
|
|||
let mut top_playlist_id = None;
|
||||
let mut trending_playlist_id = None;
|
||||
|
||||
let mut mapper_top = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper_trending = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper_other = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper_top = MusicListMapper::new(lang);
|
||||
let mut mapper_trending = MusicListMapper::new(lang);
|
||||
let mut mapper_other = MusicListMapper::new(lang);
|
||||
|
||||
self.contents
|
||||
.single_column_browse_results_renderer
|
||||
|
@ -89,9 +96,8 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
|
|||
h.music_carousel_shelf_basic_header_renderer
|
||||
.more_content_button
|
||||
.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)
|
||||
if top_playlist_id.is_none() {
|
||||
mapper_top.map_response(shelf.contents);
|
||||
|
@ -112,13 +118,17 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
|
|||
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_trending = mapper_trending.conv_items::<TrackItem>();
|
||||
let mapped_other = mapper_other.group_items();
|
||||
let mut mapped_trending = mapper_trending.conv_items::<TrackItem>();
|
||||
let mut mapped_other = mapper_other.group_items();
|
||||
|
||||
let mut warnings = mapped_top.warnings;
|
||||
warnings.extend(mapped_trending.warnings);
|
||||
warnings.extend(mapped_other.warnings);
|
||||
warnings.append(&mut mapped_trending.warnings);
|
||||
warnings.append(&mut mapped_other.warnings);
|
||||
|
||||
Ok(MapResult {
|
||||
c: MusicCharts {
|
||||
|
@ -142,6 +152,7 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::param::Language;
|
||||
|
||||
#[rstest]
|
||||
#[case::default("global")]
|
||||
|
@ -153,7 +164,7 @@ mod tests {
|
|||
|
||||
let charts: response::MusicCharts =
|
||||
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!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
use std::{borrow::Cow, fmt::Debug};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
ArtistId, Lyrics, MusicRelated, TrackDetails, TrackItem,
|
||||
},
|
||||
model::{paginator::Paginator, ArtistId, Lyrics, MusicRelated, TrackDetails, TrackItem},
|
||||
param::Language,
|
||||
serializer::MapResult,
|
||||
};
|
||||
|
||||
|
@ -16,11 +14,12 @@ use super::{
|
|||
self,
|
||||
music_item::{map_queue_item, MusicListMapper},
|
||||
},
|
||||
ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery,
|
||||
ClientType, MapResponse, QBrowse, RustyPipeQuery, YTContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct QMusicDetails<'a> {
|
||||
context: YTContext<'a>,
|
||||
video_id: &'a str,
|
||||
enable_persistent_playlist_panel: bool,
|
||||
is_audio_only: bool,
|
||||
|
@ -29,6 +28,7 @@ struct QMusicDetails<'a> {
|
|||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct QRadio<'a> {
|
||||
context: YTContext<'a>,
|
||||
playlist_id: &'a str,
|
||||
params: &'a str,
|
||||
enable_persistent_playlist_panel: bool,
|
||||
|
@ -37,14 +37,12 @@ struct QRadio<'a> {
|
|||
}
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// Get the metadata of a YouTube Music track
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_details<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
video_id: S,
|
||||
) -> Result<TrackDetails, Error> {
|
||||
/// Get the metadata of a YouTube music track
|
||||
pub async fn music_details<S: AsRef<str>>(&self, video_id: S) -> Result<TrackDetails, Error> {
|
||||
let video_id = video_id.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QMusicDetails {
|
||||
context,
|
||||
video_id,
|
||||
enable_persistent_playlist_panel: true,
|
||||
is_audio_only: true,
|
||||
|
@ -61,13 +59,14 @@ impl RustyPipeQuery {
|
|||
.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`].
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_lyrics<S: AsRef<str> + Debug>(&self, lyrics_id: S) -> Result<Lyrics, Error> {
|
||||
pub async fn music_lyrics<S: AsRef<str>>(&self, lyrics_id: S) -> Result<Lyrics, Error> {
|
||||
let lyrics_id = lyrics_id.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: lyrics_id,
|
||||
};
|
||||
|
||||
|
@ -84,13 +83,11 @@ impl RustyPipeQuery {
|
|||
/// Get related items (tracks, playlists, artists) to a YouTube Music track
|
||||
///
|
||||
/// 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> + Debug>(
|
||||
&self,
|
||||
related_id: S,
|
||||
) -> Result<MusicRelated, Error> {
|
||||
pub async fn music_related<S: AsRef<str>>(&self, related_id: S) -> Result<MusicRelated, Error> {
|
||||
let related_id = related_id.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: related_id,
|
||||
};
|
||||
|
||||
|
@ -107,13 +104,17 @@ impl RustyPipeQuery {
|
|||
/// 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.
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_radio<S: AsRef<str> + Debug>(
|
||||
pub async fn music_radio<S: AsRef<str>>(
|
||||
&self,
|
||||
radio_id: S,
|
||||
) -> Result<Paginator<TrackItem>, Error> {
|
||||
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 {
|
||||
context,
|
||||
playlist_id: radio_id,
|
||||
params: "wAEB8gECeAE%3D",
|
||||
enable_persistent_playlist_panel: true,
|
||||
|
@ -132,8 +133,7 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// 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> + Debug>(
|
||||
pub async fn music_radio_track<S: AsRef<str>>(
|
||||
&self,
|
||||
video_id: S,
|
||||
) -> Result<Paginator<TrackItem>, Error> {
|
||||
|
@ -142,8 +142,7 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// 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> + Debug>(
|
||||
pub async fn music_radio_playlist<S: AsRef<str>>(
|
||||
&self,
|
||||
playlist_id: S,
|
||||
) -> Result<Paginator<TrackItem>, Error> {
|
||||
|
@ -155,7 +154,9 @@ impl RustyPipeQuery {
|
|||
impl MapResponse<TrackDetails> for response::MusicDetails {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<TrackDetails>, ExtractionError> {
|
||||
let tabs = self
|
||||
.contents
|
||||
|
@ -193,7 +194,7 @@ impl MapResponse<TrackDetails> for response::MusicDetails {
|
|||
}
|
||||
|
||||
let content = content.ok_or_else(|| ExtractionError::NotFound {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
msg: "no content".into(),
|
||||
})?;
|
||||
let track_item = content
|
||||
|
@ -207,7 +208,14 @@ impl MapResponse<TrackDetails> for response::MusicDetails {
|
|||
response::music_item::PlaylistPanelVideo::None => None,
|
||||
})
|
||||
.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;
|
||||
warnings.append(&mut track.warnings);
|
||||
|
@ -226,7 +234,9 @@ impl MapResponse<TrackDetails> for response::MusicDetails {
|
|||
impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
|
||||
let tabs = self
|
||||
.contents
|
||||
|
@ -239,7 +249,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
|
|||
.into_iter()
|
||||
.find_map(|t| t.tab_renderer.content)
|
||||
.ok_or_else(|| ExtractionError::NotFound {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
msg: "no content".into(),
|
||||
})?
|
||||
.music_queue_renderer
|
||||
|
@ -254,7 +264,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
|
|||
.into_iter()
|
||||
.filter_map(|item| match 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);
|
||||
Some(track.c)
|
||||
}
|
||||
|
@ -274,8 +284,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
|
|||
tracks,
|
||||
ctoken,
|
||||
None,
|
||||
ContinuationEndpoint::MusicNext,
|
||||
false,
|
||||
crate::model::paginator::ContinuationEndpoint::MusicNext,
|
||||
),
|
||||
warnings,
|
||||
})
|
||||
|
@ -283,17 +292,27 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
|
|||
}
|
||||
|
||||
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
|
||||
.contents
|
||||
.into_res()
|
||||
.map_err(|msg| ExtractionError::NotFound {
|
||||
id: ctx.id.to_owned(),
|
||||
msg: msg.into(),
|
||||
})?
|
||||
.into_iter()
|
||||
.find_map(|item| item.music_description_shelf_renderer)
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?;
|
||||
.section_list_renderer
|
||||
.and_then(|sl| {
|
||||
sl.contents
|
||||
.into_iter()
|
||||
.find_map(|item| item.music_description_shelf_renderer)
|
||||
})
|
||||
.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 {
|
||||
c: Lyrics {
|
||||
|
@ -308,44 +327,43 @@ impl MapResponse<Lyrics> for response::MusicLyrics {
|
|||
impl MapResponse<MusicRelated> for response::MusicRelated {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
_id: &str,
|
||||
lang: Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> 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
|
||||
let artist_id = contents.iter().find_map(|section| match section {
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
shelf.header.as_ref().and_then(|h| {
|
||||
h.music_carousel_shelf_basic_header_renderer
|
||||
.title
|
||||
.0
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
let artist = ArtistId::from(c.clone());
|
||||
if artist.id.is_some() {
|
||||
Some(artist)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let artist_id = self
|
||||
.contents
|
||||
.section_list_renderer
|
||||
.contents
|
||||
.iter()
|
||||
.find_map(|section| match section {
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
shelf.header.as_ref().and_then(|h| {
|
||||
h.music_carousel_shelf_basic_header_renderer
|
||||
.title
|
||||
.0
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
let artist = ArtistId::from(c.clone());
|
||||
if artist.id.is_some() {
|
||||
Some(artist)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let mut mapper_tracks = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper_tracks = MusicListMapper::new(lang);
|
||||
let mut mapper = match artist_id {
|
||||
Some(artist_id) => MusicListMapper::with_artist(ctx.lang, artist_id),
|
||||
None => MusicListMapper::new(ctx.lang),
|
||||
Some(artist_id) => MusicListMapper::with_artist(lang, artist_id),
|
||||
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)) =
|
||||
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 mut mapped = mapper.group_items();
|
||||
|
||||
|
@ -389,7 +410,7 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::{model, util::tests::TESTFILES};
|
||||
use crate::{model, param::Language, util::tests::TESTFILES};
|
||||
|
||||
#[rstest]
|
||||
#[case::mv("mv", "ZeerrnuLi5E")]
|
||||
|
@ -401,7 +422,7 @@ mod tests {
|
|||
let details: response::MusicDetails =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<model::TrackDetails> =
|
||||
details.map_response(&MapRespCtx::test(id)).unwrap();
|
||||
details.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -421,7 +442,7 @@ mod tests {
|
|||
let radio: response::MusicDetails =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<Paginator<TrackItem>> =
|
||||
radio.map_response(&MapRespCtx::test(id)).unwrap();
|
||||
radio.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -438,7 +459,7 @@ mod tests {
|
|||
|
||||
let lyrics: response::MusicLyrics =
|
||||
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!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -455,7 +476,7 @@ mod tests {
|
|||
|
||||
let lyrics: response::MusicRelated =
|
||||
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!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use std::{borrow::Cow, fmt::Debug};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
error::{Error, ExtractionError},
|
||||
|
@ -7,15 +7,16 @@ use crate::{
|
|||
};
|
||||
|
||||
use super::{
|
||||
response::{self, music_item::MusicListMapper, url_endpoint::NavigationEndpoint},
|
||||
ClientType, MapRespCtx, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery,
|
||||
response::{self, music_item::MusicListMapper},
|
||||
ClientType, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery,
|
||||
};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// 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> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: "FEmusic_moods_and_genres",
|
||||
};
|
||||
|
||||
|
@ -30,13 +31,11 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Get the playlists from a YouTube Music genre
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_genre<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
genre_id: S,
|
||||
) -> Result<MusicGenre, Error> {
|
||||
pub async fn music_genre<S: AsRef<str>>(&self, genre_id: S) -> Result<MusicGenre, Error> {
|
||||
let genre_id = genre_id.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowseParams {
|
||||
context,
|
||||
browse_id: "FEmusic_moods_and_genres_category",
|
||||
params: genre_id,
|
||||
};
|
||||
|
@ -55,8 +54,10 @@ impl RustyPipeQuery {
|
|||
impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres {
|
||||
fn map_response(
|
||||
self,
|
||||
_ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<Vec<MusicGenreItem>>, ExtractionError> {
|
||||
_id: &str,
|
||||
_lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<crate::serializer::MapResult<Vec<MusicGenreItem>>, ExtractionError> {
|
||||
let content = self
|
||||
.contents
|
||||
.single_column_browse_results_renderer
|
||||
|
@ -104,7 +105,12 @@ impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
let content = self
|
||||
|
@ -138,20 +144,18 @@ impl MapResponse<MusicGenre> for response::MusicGenre {
|
|||
h.music_carousel_shelf_basic_header_renderer
|
||||
.more_content_button
|
||||
.and_then(|btn| {
|
||||
if let NavigationEndpoint::Browse {
|
||||
browse_endpoint, ..
|
||||
} = btn.button_renderer.navigation_endpoint
|
||||
{
|
||||
if browse_endpoint.browse_id
|
||||
== "FEmusic_moods_and_genres_category"
|
||||
{
|
||||
Some(browse_endpoint.params)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
btn.button_renderer
|
||||
.navigation_endpoint
|
||||
.browse_endpoint
|
||||
.and_then(|browse| {
|
||||
if browse.browse_id
|
||||
== "FEmusic_moods_and_genres_category"
|
||||
{
|
||||
Some(browse.params)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}),
|
||||
shelf.contents,
|
||||
|
@ -166,7 +170,7 @@ impl MapResponse<MusicGenre> for response::MusicGenre {
|
|||
_ => return None,
|
||||
};
|
||||
|
||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper = MusicListMapper::new(lang);
|
||||
mapper.map_response(items);
|
||||
let mut mapped = mapper.conv_items();
|
||||
warnings.append(&mut mapped.warnings);
|
||||
|
@ -181,7 +185,7 @@ impl MapResponse<MusicGenre> for response::MusicGenre {
|
|||
|
||||
Ok(MapResult {
|
||||
c: MusicGenre {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
name: self.header.music_header_renderer.title,
|
||||
sections,
|
||||
},
|
||||
|
@ -198,7 +202,7 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::{model, util::tests::TESTFILES};
|
||||
use crate::{model, param::Language, util::tests::TESTFILES};
|
||||
|
||||
#[test]
|
||||
fn map_music_genres() {
|
||||
|
@ -208,7 +212,7 @@ mod tests {
|
|||
let playlist: response::MusicGenres =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<Vec<model::MusicGenreItem>> =
|
||||
playlist.map_response(&MapRespCtx::test("")).unwrap();
|
||||
playlist.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -228,7 +232,7 @@ mod tests {
|
|||
let playlist: response::MusicGenre =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<model::MusicGenre> =
|
||||
playlist.map_response(&MapRespCtx::test(id)).unwrap();
|
||||
playlist.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
|
@ -4,16 +4,16 @@ use crate::{
|
|||
client::response::music_item::MusicListMapper,
|
||||
error::{Error, ExtractionError},
|
||||
model::{traits::FromYtItem, AlbumItem, TrackItem},
|
||||
serializer::MapResult,
|
||||
};
|
||||
|
||||
use super::{response, ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery};
|
||||
use super::{response, ClientType, MapResponse, QBrowse, RustyPipeQuery};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// 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> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: "FEmusic_new_releases_albums",
|
||||
};
|
||||
|
||||
|
@ -28,9 +28,10 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// 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> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: "FEmusic_new_releases_videos",
|
||||
};
|
||||
|
||||
|
@ -46,7 +47,12 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
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
|
||||
.contents
|
||||
.single_column_browse_results_renderer
|
||||
|
@ -64,8 +70,9 @@ impl<T: FromYtItem> MapResponse<Vec<T>> for response::MusicNew {
|
|||
.grid_renderer
|
||||
.items;
|
||||
|
||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper = MusicListMapper::new(lang);
|
||||
mapper.map_response(items);
|
||||
mapper.check_unknown()?;
|
||||
|
||||
Ok(mapper.conv_items())
|
||||
}
|
||||
|
@ -79,7 +86,7 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::{serializer::MapResult, util::tests::TESTFILES};
|
||||
use crate::{param::Language, serializer::MapResult, util::tests::TESTFILES};
|
||||
|
||||
#[rstest]
|
||||
#[case::default("default")]
|
||||
|
@ -90,7 +97,7 @@ mod tests {
|
|||
let new_albums: response::MusicNew =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<Vec<AlbumItem>> =
|
||||
new_albums.map_response(&MapRespCtx::test("")).unwrap();
|
||||
new_albums.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -102,15 +109,14 @@ mod tests {
|
|||
|
||||
#[rstest]
|
||||
#[case::default("default")]
|
||||
#[case::default("w_podcasts")]
|
||||
fn map_music_new_videos(#[case] name: &str) {
|
||||
let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json"));
|
||||
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();
|
||||
let map_res: MapResult<Vec<TrackItem>> =
|
||||
new_videos.map_response(&MapRespCtx::test("")).unwrap();
|
||||
new_albums.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
|
@ -1,36 +1,30 @@
|
|||
use std::{borrow::Cow, fmt::Debug};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
client::response::url_endpoint::NavigationEndpoint,
|
||||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
richtext::RichText,
|
||||
AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem, TrackType,
|
||||
},
|
||||
serializer::{text::TextComponents, MapResult},
|
||||
model::{paginator::Paginator, AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem},
|
||||
serializer::MapResult,
|
||||
util::{self, TryRemove, DOT_SEPARATOR},
|
||||
};
|
||||
|
||||
use self::response::url_endpoint::MusicPageType;
|
||||
|
||||
use super::{
|
||||
response::{
|
||||
self,
|
||||
music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper},
|
||||
},
|
||||
ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery,
|
||||
ClientType, MapResponse, QBrowse, RustyPipeQuery,
|
||||
};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// Get a playlist from YouTube Music
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_playlist<S: AsRef<str> + Debug>(
|
||||
pub async fn music_playlist<S: AsRef<str>>(
|
||||
&self,
|
||||
playlist_id: S,
|
||||
) -> Result<MusicPlaylist, Error> {
|
||||
let playlist_id = playlist_id.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: &format!("VL{playlist_id}"),
|
||||
};
|
||||
|
||||
|
@ -45,13 +39,11 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Get an album from YouTube Music
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_album<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
album_id: S,
|
||||
) -> Result<MusicAlbum, Error> {
|
||||
pub async fn music_album<S: AsRef<str>>(&self, album_id: S) -> Result<MusicAlbum, Error> {
|
||||
let album_id = album_id.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: album_id,
|
||||
};
|
||||
|
||||
|
@ -87,7 +79,7 @@ impl RustyPipeQuery {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, track)| {
|
||||
if track.track_type.is_video() {
|
||||
if track.is_video {
|
||||
Some((i, track.name.clone()))
|
||||
} else {
|
||||
None
|
||||
|
@ -104,7 +96,7 @@ impl RustyPipeQuery {
|
|||
|
||||
for (i, title) in to_replace {
|
||||
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))
|
||||
} else {
|
||||
None
|
||||
|
@ -115,7 +107,7 @@ impl RustyPipeQuery {
|
|||
if let Some(duration) = 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 {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<MusicPlaylist>, ExtractionError> {
|
||||
// dbg!(&self);
|
||||
|
||||
let (header, music_contents) = match self.contents {
|
||||
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => (
|
||||
self.header,
|
||||
c.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.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 music_contents = self
|
||||
.contents
|
||||
.single_column_browse_results_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer;
|
||||
let shelf = music_contents
|
||||
.contents
|
||||
.into_iter()
|
||||
|
@ -172,28 +147,26 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
|
|||
)))?;
|
||||
|
||||
if let Some(playlist_id) = shelf.playlist_id {
|
||||
if playlist_id != ctx.id {
|
||||
if playlist_id != id {
|
||||
return Err(ExtractionError::WrongResult(format!(
|
||||
"got wrong playlist id {}, expected {}",
|
||||
playlist_id, ctx.id
|
||||
"got wrong playlist id {playlist_id}, expected {id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper = MusicListMapper::new(lang);
|
||||
mapper.map_response(shelf.contents);
|
||||
|
||||
let ctoken = mapper.ctoken.clone().or_else(|| {
|
||||
shelf
|
||||
.continuations
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|cont| cont.next_continuation_data.continuation)
|
||||
});
|
||||
mapper.check_unknown()?;
|
||||
let map_res = mapper.conv_items();
|
||||
|
||||
let ctoken = shelf
|
||||
.continuations
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|cont| cont.next_continuation_data.continuation);
|
||||
|
||||
let track_count = if ctoken.is_some() {
|
||||
header.as_ref().and_then(|h| {
|
||||
self.header.as_ref().and_then(|h| {
|
||||
let parts = h
|
||||
.music_detail_header_renderer
|
||||
.second_subtitle
|
||||
|
@ -213,49 +186,23 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
|
|||
.next()
|
||||
.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) => {
|
||||
let h = header.music_detail_header_renderer;
|
||||
|
||||
let (from_ytm, channel) = match h.facepile {
|
||||
Some(facepile) => {
|
||||
let from_ytm = facepile.avatar_stack_view_model.text.starts_with("YouTube");
|
||||
let channel = facepile
|
||||
.avatar_stack_view_model
|
||||
.renderer_context
|
||||
.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)
|
||||
}
|
||||
};
|
||||
let from_ytm = h.subtitle.0.iter().any(util::is_ytm);
|
||||
let channel = h
|
||||
.subtitle
|
||||
.0
|
||||
.into_iter()
|
||||
.find_map(|c| ChannelId::try_from(c).ok());
|
||||
|
||||
(
|
||||
from_ytm,
|
||||
channel,
|
||||
h.title,
|
||||
h.thumbnail.into(),
|
||||
h.description.map(TextComponents::from),
|
||||
h.description,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
|
@ -287,28 +234,26 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
|
|||
|
||||
Ok(MapResult {
|
||||
c: MusicPlaylist {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
name,
|
||||
thumbnail,
|
||||
channel,
|
||||
description: description.map(RichText::from),
|
||||
description,
|
||||
track_count,
|
||||
from_ytm,
|
||||
tracks: Paginator::new_ext(
|
||||
track_count,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::MusicBrowse,
|
||||
ctx.authenticated,
|
||||
None,
|
||||
crate::model::paginator::ContinuationEndpoint::MusicBrowse,
|
||||
),
|
||||
related_playlists: Paginator::new_ext(
|
||||
None,
|
||||
Vec::new(),
|
||||
related_ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::MusicBrowse,
|
||||
ctx.authenticated,
|
||||
None,
|
||||
crate::model::paginator::ContinuationEndpoint::MusicBrowse,
|
||||
),
|
||||
},
|
||||
warnings: map_res.warnings,
|
||||
|
@ -317,43 +262,31 @@ impl MapResponse<MusicPlaylist> 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);
|
||||
|
||||
let (header, sections) = match self.contents {
|
||||
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => (
|
||||
self.header,
|
||||
c.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.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
|
||||
let header = self
|
||||
.header
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no header")))?
|
||||
.music_detail_header_renderer;
|
||||
|
||||
let sections = self
|
||||
.contents
|
||||
.single_column_browse_results_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents;
|
||||
|
||||
let mut shelf = None;
|
||||
let mut album_variants = None;
|
||||
for section in sections {
|
||||
|
@ -371,37 +304,27 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
|
|||
|
||||
let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR);
|
||||
|
||||
let (year_txt, artists_p) = match header.strapline_text_one {
|
||||
// New (2column) album layout
|
||||
Some(sl) => {
|
||||
let (year_txt, artists_p) = match subtitle_split.len() {
|
||||
3.. => {
|
||||
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))
|
||||
.swap_remove(2)
|
||||
.0
|
||||
.get(0)
|
||||
.map(|c| c.as_str().to_owned());
|
||||
(year_txt, subtitle_split.try_swap_remove(1))
|
||||
}
|
||||
// Old album layout
|
||||
None => match subtitle_split.len() {
|
||||
3.. => {
|
||||
let year_txt = subtitle_split
|
||||
.swap_remove(2)
|
||||
.0
|
||||
.first()
|
||||
.map(|c| c.as_str().to_owned());
|
||||
(year_txt, subtitle_split.try_swap_remove(1))
|
||||
2 => {
|
||||
// The second part may either be the year or the artist
|
||||
let p2 = subtitle_split.swap_remove(1);
|
||||
let is_year =
|
||||
p2.0.len() == 1 && p2.0[0].as_str().chars().all(|c| c.is_ascii_digit());
|
||||
if is_year {
|
||||
(Some(p2.0[0].as_str().to_owned()), None)
|
||||
} else {
|
||||
(None, Some(p2))
|
||||
}
|
||||
2 => {
|
||||
// The second part may either be the year or the artist
|
||||
let p2 = subtitle_split.swap_remove(1);
|
||||
let is_year =
|
||||
p2.0.len() == 1 && p2.0[0].as_str().chars().all(|c| c.is_ascii_digit());
|
||||
if is_year {
|
||||
(Some(p2.0[0].as_str().to_owned()), None)
|
||||
} else {
|
||||
(None, Some(p2))
|
||||
}
|
||||
}
|
||||
_ => (None, None),
|
||||
},
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
let (artists, by_va) = map_artists(artists_p);
|
||||
|
@ -411,58 +334,35 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
|
|||
.map(|part| part.to_string())
|
||||
.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());
|
||||
|
||||
fn map_playlist_id(ep: &NavigationEndpoint) -> Option<String> {
|
||||
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
|
||||
let (artist_id, playlist_id) = header
|
||||
.menu
|
||||
.or_else(|| header.buttons.into_iter().next())
|
||||
.map(|menu| {
|
||||
(
|
||||
playlist_id.or_else(|| {
|
||||
menu.menu_renderer
|
||||
.top_level_buttons
|
||||
.iter()
|
||||
.find_map(|btn| {
|
||||
map_playlist_id(&btn.button_renderer.navigation_endpoint)
|
||||
})
|
||||
.or_else(|| {
|
||||
menu.menu_renderer.items.iter().find_map(|itm| {
|
||||
map_playlist_id(
|
||||
&itm.menu_navigation_item_renderer.navigation_endpoint,
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
map_artist_id(menu.menu_renderer.items),
|
||||
menu.menu_renderer
|
||||
.top_level_buttons
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|btn| {
|
||||
btn.button_renderer
|
||||
.navigation_endpoint
|
||||
.watch_playlist_endpoint
|
||||
.playlist_id
|
||||
}),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let artist_id = artist_id.or_else(|| artists.first().and_then(|a| a.id.clone()));
|
||||
|
||||
let mut mapper = MusicListMapper::with_album(
|
||||
ctx.lang,
|
||||
lang,
|
||||
artists.clone(),
|
||||
by_va,
|
||||
AlbumId {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
name: header.title.clone(),
|
||||
},
|
||||
);
|
||||
|
@ -470,7 +370,7 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
|
|||
let tracks_res = mapper.conv_items();
|
||||
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 {
|
||||
variants_mapper.map_response(res);
|
||||
}
|
||||
|
@ -479,15 +379,13 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
|
|||
|
||||
Ok(MapResult {
|
||||
c: MusicAlbum {
|
||||
id: ctx.id.to_owned(),
|
||||
id: id.to_owned(),
|
||||
playlist_id,
|
||||
name: header.title,
|
||||
cover: header.thumbnail.into(),
|
||||
artists,
|
||||
artist_id,
|
||||
description: header
|
||||
.description
|
||||
.map(|t| RichText::from(TextComponents::from(t))),
|
||||
description: header.description,
|
||||
album_type,
|
||||
year,
|
||||
by_va,
|
||||
|
@ -507,15 +405,12 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::{model, util::tests::TESTFILES};
|
||||
use crate::{model, param::Language, util::tests::TESTFILES};
|
||||
|
||||
#[rstest]
|
||||
#[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")]
|
||||
#[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")]
|
||||
#[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) {
|
||||
let json_path = path!(*TESTFILES / "music_playlist" / format!("playlist_{name}.json"));
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
@ -523,7 +418,7 @@ mod tests {
|
|||
let playlist: response::MusicPlaylist =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<model::MusicPlaylist> =
|
||||
playlist.map_response(&MapRespCtx::test(id)).unwrap();
|
||||
playlist.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -541,8 +436,6 @@ mod tests {
|
|||
#[case::single("single", "MPREb_bHfHGoy7vuv")]
|
||||
#[case::description("description", "MPREb_PiyfuVl6aYd")]
|
||||
#[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) {
|
||||
let json_path = path!(*TESTFILES / "music_playlist" / format!("album_{name}.json"));
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
@ -550,7 +443,7 @@ mod tests {
|
|||
let playlist: response::MusicPlaylist =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<model::MusicAlbum> =
|
||||
playlist.map_response(&MapRespCtx::test(id)).unwrap();
|
||||
playlist.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use std::{borrow::Cow, fmt::Debug};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
|
@ -6,45 +6,96 @@ use crate::{
|
|||
client::response::music_item::MusicListMapper,
|
||||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
traits::FromYtItem,
|
||||
AlbumItem, ArtistItem, MusicItem, MusicPlaylistItem, MusicSearchResult,
|
||||
MusicSearchSuggestion, TrackItem, UserItem,
|
||||
paginator::Paginator, traits::FromYtItem, AlbumItem, ArtistItem, MusicPlaylistItem,
|
||||
MusicSearchFiltered, MusicSearchResult, MusicSearchSuggestion, TrackItem,
|
||||
},
|
||||
param::search_filter::MusicSearchFilter,
|
||||
serializer::MapResult,
|
||||
};
|
||||
|
||||
use super::{response, ClientType, MapRespCtx, MapResponse, RustyPipeQuery};
|
||||
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QSearch<'a> {
|
||||
context: YTContext<'a>,
|
||||
query: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<&'a str>,
|
||||
params: Option<Params>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QSearchSuggestion<'a> {
|
||||
context: YTContext<'a>,
|
||||
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 {
|
||||
/// Search YouTube Music.
|
||||
///
|
||||
/// This is a generic implementation which casts items to the given type or filters
|
||||
/// them out.
|
||||
pub async fn music_search<T: FromYtItem, S: AsRef<str>>(
|
||||
/// Search YouTube Music. Returns items from any type.
|
||||
pub async fn music_search<S: AsRef<str>>(&self, query: S) -> Result<MusicSearchResult, Error> {
|
||||
let query = query.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
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,
|
||||
query: S,
|
||||
filter: Option<MusicSearchFilter>,
|
||||
) -> Result<MusicSearchResult<T>, Error> {
|
||||
) -> Result<MusicSearchFiltered<TrackItem>, 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 context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QSearch {
|
||||
context,
|
||||
query,
|
||||
params: filter.map(MusicSearchFilter::params),
|
||||
params: Some(params),
|
||||
};
|
||||
|
||||
self.execute_request::<response::MusicSearch, _, _>(
|
||||
|
@ -57,87 +108,111 @@ impl RustyPipeQuery {
|
|||
.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
|
||||
pub async fn music_search_albums<S: AsRef<str>>(
|
||||
&self,
|
||||
query: S,
|
||||
) -> Result<MusicSearchResult<AlbumItem>, Error> {
|
||||
self.music_search(query, Some(MusicSearchFilter::Albums))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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(
|
||||
) -> Result<MusicSearchFiltered<AlbumItem>, Error> {
|
||||
let query = query.as_ref();
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QSearch {
|
||||
context,
|
||||
query,
|
||||
Some(if community {
|
||||
MusicSearchFilter::CommunityPlaylists
|
||||
} else {
|
||||
MusicSearchFilter::YtmPlaylists
|
||||
}),
|
||||
params: Some(Params::Albums),
|
||||
};
|
||||
|
||||
self.execute_request::<response::MusicSearch, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_search_albums",
|
||||
query,
|
||||
"search",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Search YouTube Music users
|
||||
pub async fn music_search_users<S: AsRef<str>>(
|
||||
/// Search YouTube Music artists
|
||||
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,
|
||||
query: S,
|
||||
) -> Result<MusicSearchResult<UserItem>, Error> {
|
||||
self.music_search(query, Some(MusicSearchFilter::Users))
|
||||
.await
|
||||
) -> Result<MusicSearchFiltered<MusicPlaylistItem>, Error> {
|
||||
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
|
||||
}
|
||||
|
||||
/// Get YouTube Music search suggestions
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_search_suggestion<S: AsRef<str> + Debug>(
|
||||
pub async fn music_search_suggestion<S: AsRef<str>>(
|
||||
&self,
|
||||
query: S,
|
||||
) -> Result<MusicSearchSuggestion, Error> {
|
||||
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, _, _>(
|
||||
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(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<MusicSearchResult<T>>, ExtractionError> {
|
||||
_id: &str,
|
||||
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);
|
||||
|
||||
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 ctoken = None;
|
||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||
let mut mapper = MusicListMapper::new(lang);
|
||||
|
||||
sections.into_iter().for_each(|section| match section {
|
||||
response::music_search::ItemSection::MusicShelfRenderer(shelf) => {
|
||||
|
@ -189,18 +331,17 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch
|
|||
response::music_search::ItemSection::None => {}
|
||||
});
|
||||
|
||||
let ctoken = ctoken.or(mapper.ctoken.clone());
|
||||
mapper.check_unknown()?;
|
||||
let map_res = mapper.conv_items();
|
||||
|
||||
Ok(MapResult {
|
||||
c: MusicSearchResult {
|
||||
c: MusicSearchFiltered {
|
||||
items: Paginator::new_ext(
|
||||
None,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::MusicSearch,
|
||||
false,
|
||||
None,
|
||||
crate::model::paginator::ContinuationEndpoint::MusicSearch,
|
||||
),
|
||||
corrected_query,
|
||||
},
|
||||
|
@ -212,9 +353,11 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch
|
|||
impl MapResponse<MusicSearchSuggestion> for response::MusicSearchSuggestion {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
_id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<MusicSearchSuggestion>, ExtractionError> {
|
||||
let mut mapper = MusicListMapper::new_search_suggest(ctx.lang);
|
||||
let mut mapper = MusicListMapper::new(lang);
|
||||
let mut terms = Vec::new();
|
||||
|
||||
for section in self.contents {
|
||||
|
@ -233,6 +376,7 @@ impl MapResponse<MusicSearchSuggestion> for response::MusicSearchSuggestion {
|
|||
}
|
||||
}
|
||||
|
||||
mapper.check_unknown()?;
|
||||
let map_res = mapper.conv_items();
|
||||
|
||||
Ok(MapResult {
|
||||
|
@ -253,11 +397,12 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use crate::{
|
||||
client::{response, MapRespCtx, MapResponse},
|
||||
client::{response, MapResponse},
|
||||
model::{
|
||||
AlbumItem, ArtistItem, MusicItem, MusicPlaylistItem, MusicSearchResult,
|
||||
AlbumItem, ArtistItem, MusicPlaylistItem, MusicSearchFiltered, MusicSearchResult,
|
||||
MusicSearchSuggestion, TrackItem,
|
||||
},
|
||||
param::Language,
|
||||
serializer::MapResult,
|
||||
util::tests::TESTFILES,
|
||||
};
|
||||
|
@ -267,15 +412,14 @@ mod tests {
|
|||
#[case::typo("typo")]
|
||||
#[case::radio("radio")]
|
||||
#[case::artist("artist")]
|
||||
#[case::live("live")]
|
||||
fn map_music_search_main(#[case] name: &str) {
|
||||
let json_path = path!(*TESTFILES / "music_search" / format!("main_{name}.json"));
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let search: response::MusicSearch =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicSearchResult<MusicItem>> =
|
||||
search.map_response(&MapRespCtx::test("")).unwrap();
|
||||
let map_res: MapResult<MusicSearchResult> =
|
||||
search.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -297,8 +441,8 @@ mod tests {
|
|||
|
||||
let search: response::MusicSearch =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicSearchResult<TrackItem>> =
|
||||
search.map_response(&MapRespCtx::test("")).unwrap();
|
||||
let map_res: MapResult<MusicSearchFiltered<TrackItem>> =
|
||||
search.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -316,8 +460,8 @@ mod tests {
|
|||
|
||||
let search: response::MusicSearch =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicSearchResult<AlbumItem>> =
|
||||
search.map_response(&MapRespCtx::test("")).unwrap();
|
||||
let map_res: MapResult<MusicSearchFiltered<AlbumItem>> =
|
||||
search.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -335,8 +479,8 @@ mod tests {
|
|||
|
||||
let search: response::MusicSearch =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicSearchResult<ArtistItem>> =
|
||||
search.map_response(&MapRespCtx::test("")).unwrap();
|
||||
let map_res: MapResult<MusicSearchFiltered<ArtistItem>> =
|
||||
search.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -356,8 +500,8 @@ mod tests {
|
|||
|
||||
let search: response::MusicSearch =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicSearchResult<MusicPlaylistItem>> =
|
||||
search.map_response(&MapRespCtx::test("")).unwrap();
|
||||
let map_res: MapResult<MusicSearchFiltered<MusicPlaylistItem>> =
|
||||
search.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -378,7 +522,7 @@ mod tests {
|
|||
let suggestion: response::MusicSearchSuggestion =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicSearchSuggestion> =
|
||||
suggestion.map_response(&MapRespCtx::test("")).unwrap();
|
||||
suggestion.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
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::model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
|
@ -8,21 +6,12 @@ use crate::model::{
|
|||
};
|
||||
use crate::serializer::MapResult;
|
||||
|
||||
#[cfg(feature = "userdata")]
|
||||
use crate::model::{HistoryItem, TrackItem, VideoItem};
|
||||
|
||||
use super::response::{
|
||||
music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo},
|
||||
YouTubeListItem,
|
||||
};
|
||||
use super::{
|
||||
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
|
||||
};
|
||||
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
|
||||
use super::{response, ClientType, MapResponse, QContinuation, RustyPipeQuery};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// 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> + Debug>(
|
||||
pub async fn continuation<T: FromYtItem, S: AsRef<str>>(
|
||||
&self,
|
||||
ctoken: S,
|
||||
endpoint: ContinuationEndpoint,
|
||||
|
@ -30,118 +19,107 @@ impl RustyPipeQuery {
|
|||
) -> Result<Paginator<T>, Error> {
|
||||
let ctoken = ctoken.as_ref();
|
||||
if endpoint.is_music() {
|
||||
let context = self
|
||||
.get_context(ClientType::DesktopMusic, true, visitor_data)
|
||||
.await;
|
||||
let request_body = QContinuation {
|
||||
context,
|
||||
continuation: ctoken,
|
||||
};
|
||||
|
||||
let p = self
|
||||
.execute_request_ctx::<response::MusicContinuation, Paginator<MusicItem>, _>(
|
||||
.execute_request::<response::MusicContinuation, Paginator<MusicItem>, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_continuation",
|
||||
ctoken,
|
||||
endpoint.as_str(),
|
||||
&request_body,
|
||||
MapRespOptions {
|
||||
visitor_data,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(map_ytm_paginator(p, endpoint))
|
||||
Ok(map_ytm_paginator(p, visitor_data, endpoint))
|
||||
} else {
|
||||
let context = self
|
||||
.get_context(ClientType::Desktop, true, visitor_data)
|
||||
.await;
|
||||
let request_body = QContinuation {
|
||||
context,
|
||||
continuation: ctoken,
|
||||
};
|
||||
|
||||
let p = self
|
||||
.execute_request_ctx::<response::Continuation, Paginator<YouTubeItem>, _>(
|
||||
.execute_request::<response::Continuation, Paginator<YouTubeItem>, _>(
|
||||
ClientType::Desktop,
|
||||
"continuation",
|
||||
ctoken,
|
||||
endpoint.as_str(),
|
||||
&request_body,
|
||||
MapRespOptions {
|
||||
visitor_data,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(map_yt_paginator(p, endpoint))
|
||||
Ok(map_yt_paginator(p, visitor_data, endpoint))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_yt_paginator<T: FromYtItem>(
|
||||
p: Paginator<YouTubeItem>,
|
||||
visitor_data: Option<&str>,
|
||||
endpoint: ContinuationEndpoint,
|
||||
) -> Paginator<T> {
|
||||
Paginator {
|
||||
count: p.count,
|
||||
items: p.items.into_iter().filter_map(T::from_yt_item).collect(),
|
||||
ctoken: p.ctoken,
|
||||
visitor_data: p.visitor_data,
|
||||
visitor_data: visitor_data.map(str::to_owned),
|
||||
endpoint,
|
||||
authenticated: p.authenticated,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_ytm_paginator<T: FromYtItem>(
|
||||
p: Paginator<MusicItem>,
|
||||
visitor_data: Option<&str>,
|
||||
endpoint: ContinuationEndpoint,
|
||||
) -> Paginator<T> {
|
||||
Paginator {
|
||||
count: p.count,
|
||||
items: p.items.into_iter().filter_map(T::from_ytm_item).collect(),
|
||||
ctoken: p.ctoken,
|
||||
visitor_data: p.visitor_data,
|
||||
visitor_data: visitor_data.map(str::to_owned),
|
||||
endpoint,
|
||||
authenticated: p.authenticated,
|
||||
}
|
||||
}
|
||||
|
||||
fn continuation_items(response: response::Continuation) -> MapResult<Vec<YouTubeListItem>> {
|
||||
response
|
||||
.on_response_received_actions
|
||||
.and_then(|actions| {
|
||||
actions
|
||||
.into_iter()
|
||||
.map(|action| action.append_continuation_items_action.continuation_items)
|
||||
.reduce(|mut acc, mut items| {
|
||||
acc.c.append(&mut items.c);
|
||||
acc.warnings.append(&mut items.warnings);
|
||||
acc
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
response
|
||||
.continuation_contents
|
||||
.map(|contents| contents.rich_grid_continuation.contents)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
_id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> {
|
||||
let estimated_results = self.estimated_results;
|
||||
let items = continuation_items(self);
|
||||
let items = self
|
||||
.on_response_received_actions
|
||||
.and_then(|actions| {
|
||||
actions
|
||||
.into_iter()
|
||||
.map(|action| action.append_continuation_items_action.continuation_items)
|
||||
.reduce(|mut acc, mut items| {
|
||||
acc.c.append(&mut items.c);
|
||||
acc.warnings.append(&mut items.warnings);
|
||||
acc
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.continuation_contents
|
||||
.map(|contents| contents.rich_grid_continuation.contents)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
|
||||
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
|
||||
mapper.map_response(items);
|
||||
|
||||
Ok(MapResult {
|
||||
c: Paginator::new_ext(
|
||||
estimated_results,
|
||||
mapper.items,
|
||||
mapper.ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::Browse,
|
||||
ctx.authenticated,
|
||||
),
|
||||
c: Paginator::new(self.estimated_results, mapper.items, mapper.ctoken),
|
||||
warnings: mapper.warnings,
|
||||
})
|
||||
}
|
||||
|
@ -150,13 +128,11 @@ impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
|
|||
impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
_id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<Paginator<MusicItem>>, ExtractionError> {
|
||||
let mut mapper = if let Some(artist) = &ctx.artist {
|
||||
MusicListMapper::with_artist(ctx.lang, artist.clone())
|
||||
} else {
|
||||
MusicListMapper::new(ctx.lang)
|
||||
};
|
||||
let mut mapper = MusicListMapper::new(lang);
|
||||
let mut continuations = Vec::new();
|
||||
|
||||
match self.continuation_contents {
|
||||
|
@ -174,11 +150,7 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
|||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
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);
|
||||
panel.contents.c.into_iter().for_each(|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_warnings(&mut track.warnings);
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(response::music_item::ContinuationContents::GridContinuation(mut grid)) => {
|
||||
mapper.map_response(grid.items);
|
||||
continuations.append(&mut grid.continuations);
|
||||
}
|
||||
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();
|
||||
|
||||
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
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|cont| cont.next_continuation_data.continuation);
|
||||
|
||||
Ok(MapResult {
|
||||
c: Paginator::new_ext(
|
||||
None,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::MusicBrowse,
|
||||
ctx.authenticated,
|
||||
),
|
||||
c: Paginator::new(None, map_res.c, ctoken),
|
||||
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)
|
||||
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
|
||||
Ok(match &self.ctoken {
|
||||
Some(ctoken) => {
|
||||
let q = if self.authenticated {
|
||||
&query.as_ref().clone().authenticated()
|
||||
} else {
|
||||
query.as_ref()
|
||||
};
|
||||
|
||||
Some(
|
||||
q.continuation(ctoken, self.endpoint, self.visitor_data.as_deref())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
Some(ctoken) => Some(
|
||||
query
|
||||
.as_ref()
|
||||
.continuation(ctoken, self.endpoint, self.visitor_data.as_deref())
|
||||
.await?,
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
@ -352,9 +206,6 @@ impl<T: FromYtItem> Paginator<T> {
|
|||
let mut items = paginator.items;
|
||||
self.items.append(&mut items);
|
||||
self.ctoken = paginator.ctoken;
|
||||
if paginator.visitor_data.is_some() {
|
||||
self.visitor_data = paginator.visitor_data;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => Ok(false),
|
||||
|
@ -397,19 +248,6 @@ impl<T: FromYtItem> Paginator<T> {
|
|||
}
|
||||
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> {
|
||||
|
@ -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 {
|
||||
($entity_type:ty) => {
|
||||
impl Paginator<$entity_type> {
|
||||
|
@ -476,9 +280,6 @@ macro_rules! paginator {
|
|||
let mut items = paginator.items;
|
||||
self.items.append(&mut items);
|
||||
self.ctoken = paginator.ctoken;
|
||||
if paginator.visitor_data.is_some() {
|
||||
self.visitor_data = paginator.visitor_data;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => Ok(false),
|
||||
|
@ -521,33 +322,11 @@ macro_rules! paginator {
|
|||
}
|
||||
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);
|
||||
#[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)]
|
||||
mod tests {
|
||||
|
@ -558,15 +337,14 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use crate::{
|
||||
model::{
|
||||
AlbumItem, ArtistItem, ChannelItem, MusicPlaylistItem, PlaylistItem, TrackItem,
|
||||
VideoItem,
|
||||
},
|
||||
model::{MusicPlaylistItem, PlaylistItem, TrackItem, VideoItem},
|
||||
param::Language,
|
||||
util::tests::TESTFILES,
|
||||
};
|
||||
|
||||
#[rstest]
|
||||
#[case::search("search", path!("search" / "cont.json"))]
|
||||
#[case::startpage("startpage", path!("trends" / "startpage_cont.json"))]
|
||||
#[case::recommendations("recommendations", path!("video_details" / "recommendations.json"))]
|
||||
fn map_continuation_items(#[case] name: &str, #[case] path: PathBuf) {
|
||||
let json_path = path!(*TESTFILES / path);
|
||||
|
@ -575,7 +353,7 @@ mod tests {
|
|||
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();
|
||||
items.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -597,9 +375,9 @@ mod tests {
|
|||
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();
|
||||
items.map_response("", Language::En, None).unwrap();
|
||||
let paginator: Paginator<VideoItem> =
|
||||
map_yt_paginator(map_res.c, ContinuationEndpoint::Browse);
|
||||
map_yt_paginator(map_res.c, None, ContinuationEndpoint::Browse);
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -620,30 +398,9 @@ mod tests {
|
|||
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();
|
||||
items.map_response("", Language::En, None).unwrap();
|
||||
let paginator: Paginator<PlaylistItem> =
|
||||
map_yt_paginator(map_res.c, 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);
|
||||
map_yt_paginator(map_res.c, None, ContinuationEndpoint::Browse);
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -657,7 +414,6 @@ mod tests {
|
|||
#[case::playlist_tracks("playlist_tracks", path!("music_playlist" / "playlist_cont.json"))]
|
||||
#[case::search_tracks("search_tracks", path!("music_search" / "tracks_cont.json"))]
|
||||
#[case::radio_tracks("radio_tracks", path!("music_details" / "radio_cont.json"))]
|
||||
#[case::saved_tracks("saved_tracks", path!("music_userdata" / "saved_tracks.json"))]
|
||||
fn map_continuation_tracks(#[case] name: &str, #[case] path: PathBuf) {
|
||||
let json_path = path!(*TESTFILES / path);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
@ -665,51 +421,9 @@ mod tests {
|
|||
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();
|
||||
items.map_response("", Language::En, None).unwrap();
|
||||
let paginator: Paginator<TrackItem> =
|
||||
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_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);
|
||||
map_ytm_paginator(map_res.c, None, ContinuationEndpoint::MusicBrowse);
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
@ -721,7 +435,6 @@ mod tests {
|
|||
|
||||
#[rstest]
|
||||
#[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) {
|
||||
let json_path = path!(*TESTFILES / path);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
@ -729,9 +442,9 @@ mod tests {
|
|||
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();
|
||||
items.map_response("", Language::En, None).unwrap();
|
||||
let paginator: Paginator<MusicPlaylistItem> =
|
||||
map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse);
|
||||
map_ytm_paginator(map_res.c, None, ContinuationEndpoint::MusicBrowse);
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
1036
src/client/player.rs
|
@ -1,26 +1,22 @@
|
|||
use std::{borrow::Cow, convert::TryFrom, fmt::Debug};
|
||||
use std::{borrow::Cow, convert::TryFrom};
|
||||
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
richtext::RichText,
|
||||
ChannelId, Playlist, VideoItem,
|
||||
},
|
||||
serializer::text::{TextComponent, TextComponents},
|
||||
util::{self, dictionary, timeago, TryRemove},
|
||||
model::{paginator::Paginator, ChannelId, Playlist, VideoItem},
|
||||
util::{self, timeago, TryRemove},
|
||||
};
|
||||
|
||||
use super::{response, ClientType, MapRespCtx, MapResponse, MapResult, QBrowse, RustyPipeQuery};
|
||||
use super::{response, ClientType, MapResponse, MapResult, QBrowse, RustyPipeQuery};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// Get a YouTube playlist
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn playlist<S: AsRef<str> + Debug>(&self, playlist_id: S) -> Result<Playlist, Error> {
|
||||
pub async fn playlist<S: AsRef<str>>(&self, playlist_id: S) -> Result<Playlist, Error> {
|
||||
let playlist_id = playlist_id.as_ref();
|
||||
let context = self.get_context(ClientType::Desktop, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: &format!("VL{playlist_id}"),
|
||||
};
|
||||
|
||||
|
@ -36,9 +32,14 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
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 {
|
||||
return Err(response::alerts_to_err(ctx.id, self.alerts));
|
||||
return Err(response::alerts_to_err(id, self.alerts));
|
||||
};
|
||||
|
||||
let video_items = contents
|
||||
|
@ -68,10 +69,10 @@ impl MapResponse<Playlist> for response::Playlist {
|
|||
.playlist_video_list_renderer
|
||||
.contents;
|
||||
|
||||
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
|
||||
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(lang);
|
||||
mapper.map_response(video_items);
|
||||
|
||||
let (description, thumbnails, last_update_txt) = match self.sidebar {
|
||||
let (thumbnails, last_update_txt) = match self.sidebar {
|
||||
Some(sidebar) => {
|
||||
let sidebar_items = sidebar.playlist_sidebar_renderer.contents;
|
||||
let mut primary =
|
||||
|
@ -85,155 +86,73 @@ impl MapResponse<Playlist> for response::Playlist {
|
|||
(
|
||||
primary
|
||||
.playlist_sidebar_primary_info_renderer
|
||||
.description
|
||||
.filter(|d| !d.0.is_empty()),
|
||||
Some(
|
||||
primary
|
||||
.playlist_sidebar_primary_info_renderer
|
||||
.thumbnail_renderer
|
||||
.playlist_video_thumbnail_renderer
|
||||
.thumbnail,
|
||||
),
|
||||
.thumbnail_renderer
|
||||
.playlist_video_thumbnail_renderer
|
||||
.thumbnail,
|
||||
primary
|
||||
.playlist_sidebar_primary_info_renderer
|
||||
.stats
|
||||
.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 mut byline = header.playlist_header_renderer.byline;
|
||||
let last_update_txt = byline
|
||||
.try_swap_remove(1)
|
||||
.map(|b| b.playlist_byline_renderer.text);
|
||||
|
||||
(
|
||||
header_banner.hero_playlist_thumbnail_renderer.thumbnail,
|
||||
last_update_txt,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let (name, playlist_id, channel, n_videos_txt, description2, thumbnails2, last_update_txt2) =
|
||||
match header {
|
||||
response::playlist::Header::PlaylistHeaderRenderer(header_renderer) => {
|
||||
let mut byline = header_renderer.byline;
|
||||
let last_update_txt = byline
|
||||
.try_swap_remove(1)
|
||||
.map(|b| b.playlist_byline_renderer.text);
|
||||
|
||||
(
|
||||
header_renderer.title,
|
||||
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,
|
||||
)
|
||||
}
|
||||
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() {
|
||||
util::parse_numeric(&n_videos_txt)
|
||||
.map_err(|_| ExtractionError::InvalidData("no video count".into()))?
|
||||
util::parse_numeric(&header.playlist_header_renderer.num_videos_text)
|
||||
.map_err(|_| ExtractionError::InvalidData(Cow::Borrowed("no video count")))?
|
||||
} else {
|
||||
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!(
|
||||
"got wrong playlist id {}, expected {}",
|
||||
playlist_id, ctx.id
|
||||
"got wrong playlist id {playlist_id}, expected {id}"
|
||||
)));
|
||||
}
|
||||
|
||||
let description = description.or(description2).map(RichText::from);
|
||||
let thumbnails = thumbnails
|
||||
.or(thumbnails2)
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
|
||||
"no thumbnail found",
|
||||
)))?;
|
||||
let last_update = last_update_txt
|
||||
.as_deref()
|
||||
.or(last_update_txt2.as_deref())
|
||||
.and_then(|txt| {
|
||||
timeago::parse_textual_date_or_warn(
|
||||
ctx.lang,
|
||||
ctx.utc_offset,
|
||||
txt,
|
||||
&mut mapper.warnings,
|
||||
)
|
||||
let name = header.playlist_header_renderer.title;
|
||||
let description = header.playlist_header_renderer.description_text;
|
||||
let channel = header
|
||||
.playlist_header_renderer
|
||||
.owner_text
|
||||
.and_then(|link| ChannelId::try_from(link).ok());
|
||||
|
||||
let last_update = last_update_txt.as_ref().and_then(|txt| {
|
||||
timeago::parse_textual_date_or_warn(lang, txt, &mut mapper.warnings)
|
||||
.map(OffsetDateTime::date)
|
||||
});
|
||||
});
|
||||
|
||||
Ok(MapResult {
|
||||
c: Playlist {
|
||||
id: playlist_id,
|
||||
name,
|
||||
videos: Paginator::new_ext(
|
||||
Some(n_videos),
|
||||
mapper.items,
|
||||
mapper.ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::Browse,
|
||||
ctx.authenticated,
|
||||
),
|
||||
videos: Paginator::new(Some(n_videos), mapper.items, mapper.ctoken),
|
||||
video_count: n_videos,
|
||||
thumbnail: thumbnails.into(),
|
||||
description,
|
||||
channel,
|
||||
last_update,
|
||||
last_update_txt,
|
||||
visitor_data: self
|
||||
.response_context
|
||||
.visitor_data
|
||||
.or_else(|| ctx.visitor_data.map(str::to_owned)),
|
||||
visitor_data: self.response_context.visitor_data,
|
||||
},
|
||||
warnings: mapper.warnings,
|
||||
})
|
||||
|
@ -247,7 +166,7 @@ mod tests {
|
|||
use path_macro::path;
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::util::tests::TESTFILES;
|
||||
use crate::{param::Language, util::tests::TESTFILES};
|
||||
|
||||
use super::*;
|
||||
|
||||
|
@ -256,14 +175,13 @@ mod tests {
|
|||
#[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")]
|
||||
#[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
|
||||
#[case::live("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw")]
|
||||
#[case::pageheader("20241011_pageheader", "PLT2w2oBf1TZKyvY_M6JsASs73m-wjLzH5")]
|
||||
fn map_playlist_data(#[case] name: &str, #[case] id: &str) {
|
||||
let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json"));
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let playlist: response::Playlist =
|
||||
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!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
|
@ -2,14 +2,10 @@ use serde::Deserialize;
|
|||
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use super::{
|
||||
video_item::YouTubeListRenderer, Alert, AttachmentRun, AvatarViewModel, ChannelBadge,
|
||||
ContentRenderer, ContentsRenderer, ContinuationActionWrap, ImageView,
|
||||
PageHeaderRendererContent, PhMetadataView, ResponseContext, Thumbnails, TwoColumnBrowseResults,
|
||||
};
|
||||
use crate::{
|
||||
model::Verification,
|
||||
serializer::text::{AttributedText, Text, TextComponent},
|
||||
video_item::YouTubeListRenderer, Alert, ChannelBadge, ContentsRenderer, ResponseContext,
|
||||
Thumbnails, TwoColumnBrowseResults,
|
||||
};
|
||||
use crate::serializer::text::Text;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -40,7 +36,7 @@ pub(crate) struct TabRendererWrap {
|
|||
pub(crate) struct TabRenderer {
|
||||
#[serde(default)]
|
||||
pub content: TabContent,
|
||||
pub endpoint: Option<ChannelTabEndpoint>,
|
||||
pub endpoint: ChannelTabEndpoint,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
@ -75,12 +71,10 @@ pub(crate) struct ChannelTabWebCommandMetadata {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub(crate) enum Header {
|
||||
C4TabbedHeaderRenderer(HeaderRenderer),
|
||||
/// Used for special channels like YouTube Music
|
||||
CarouselHeaderRenderer(ContentsRenderer<CarouselHeaderRendererItem>),
|
||||
PageHeaderRenderer(ContentRenderer<PageHeaderRendererContent<PageHeaderRendererInner>>),
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
@ -99,6 +93,11 @@ pub(crate) struct HeaderRenderer {
|
|||
pub badges: Vec<ChannelBadge>,
|
||||
#[serde(default)]
|
||||
pub banner: Thumbnails,
|
||||
#[serde(default)]
|
||||
pub mobile_banner: Thumbnails,
|
||||
/// Fullscreen (16:9) channel banner
|
||||
#[serde(default)]
|
||||
pub tv_banner: Thumbnails,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
@ -118,59 +117,6 @@ pub(crate) enum CarouselHeaderRendererItem {
|
|||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Metadata {
|
||||
|
@ -199,85 +145,3 @@ pub(crate) struct MicroformatDataRenderer {
|
|||
#[serde(default)]
|
||||
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 time::OffsetDateTime;
|
||||
|
||||
use crate::util;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChannelRss {
|
||||
#[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) use channel::Channel;
|
||||
pub(crate) use channel::ChannelAbout;
|
||||
pub(crate) use music_artist::MusicArtist;
|
||||
pub(crate) use music_artist::MusicArtistAlbums;
|
||||
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_search::MusicSearch;
|
||||
pub(crate) use music_search::MusicSearchSuggestion;
|
||||
pub(crate) use player::DrmLicense;
|
||||
pub(crate) use player::Player;
|
||||
pub(crate) use playlist::Playlist;
|
||||
pub(crate) use search::Search;
|
||||
pub(crate) use search::SearchSuggestion;
|
||||
pub(crate) use trends::Startpage;
|
||||
pub(crate) use trends::Trending;
|
||||
pub(crate) use url_endpoint::ResolvedUrl;
|
||||
pub(crate) use video_details::VideoComments;
|
||||
|
@ -47,28 +46,17 @@ pub(crate) mod channel_rss;
|
|||
#[cfg(feature = "rss")]
|
||||
pub(crate) use channel_rss::ChannelRss;
|
||||
|
||||
#[cfg(feature = "userdata")]
|
||||
pub(crate) mod history;
|
||||
#[cfg(feature = "userdata")]
|
||||
pub(crate) use history::History;
|
||||
#[cfg(feature = "userdata")]
|
||||
pub(crate) mod music_history;
|
||||
#[cfg(feature = "userdata")]
|
||||
pub(crate) use music_history::MusicHistory;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use serde::{
|
||||
de::{IgnoredAny, Visitor},
|
||||
Deserialize,
|
||||
};
|
||||
use serde_with::{serde_as, DisplayFromStr, VecSkipError};
|
||||
use serde_with::{json::JsonString, serde_as, VecSkipError};
|
||||
|
||||
use crate::error::ExtractionError;
|
||||
use crate::serializer::text::{AttributedText, Text, TextComponent};
|
||||
use crate::serializer::{MapResult, VecSkipErrorWrap};
|
||||
use crate::serializer::{text::Text, MapResult, VecSkipErrorWrap};
|
||||
|
||||
use self::video_item::YouTubeListRenderer;
|
||||
|
||||
|
@ -78,9 +66,6 @@ pub(crate) struct ContentRenderer<T> {
|
|||
pub content: T,
|
||||
}
|
||||
|
||||
/// Deserializes any object with an array field named `contents`, `tabs` or `items`.
|
||||
///
|
||||
/// Invalid items are skipped
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ContentsRenderer<T> {
|
||||
pub contents: Vec<T>,
|
||||
|
@ -117,24 +102,12 @@ pub(crate) struct ThumbnailsWrap {
|
|||
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.
|
||||
/// Not only used for thumbnails, but also for avatars and banners.
|
||||
#[derive(Default, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Thumbnails {
|
||||
#[serde(default, alias = "sources")]
|
||||
#[serde(default)]
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
}
|
||||
|
||||
|
@ -204,92 +177,23 @@ pub(crate) enum ChannelBadgeStyle {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Alert {
|
||||
pub alert_renderer: TextBox,
|
||||
pub alert_renderer: AlertRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TextBox {
|
||||
pub(crate) struct AlertRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ResponseContext {
|
||||
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
|
||||
|
||||
#[serde_as]
|
||||
|
@ -297,14 +201,14 @@ pub enum IconName {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Continuation {
|
||||
/// Number of search results
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub estimated_results: Option<u64>,
|
||||
#[serde(
|
||||
alias = "onResponseReceivedCommands",
|
||||
alias = "onResponseReceivedEndpoints"
|
||||
)]
|
||||
#[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
|
||||
///
|
||||
/// A/B test seen on 19.10.2022
|
||||
|
@ -313,15 +217,15 @@ pub(crate) struct Continuation {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContinuationActionWrap<T> {
|
||||
pub(crate) struct ContinuationActionWrap {
|
||||
#[serde(alias = "reloadContinuationItemsCommand")]
|
||||
pub append_continuation_items_action: ContinuationAction<T>,
|
||||
pub append_continuation_items_action: ContinuationAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContinuationAction<T> {
|
||||
pub continuation_items: MapResult<Vec<T>>,
|
||||
pub(crate) struct ContinuationAction {
|
||||
pub continuation_items: MapResult<Vec<YouTubeListItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -428,27 +332,14 @@ 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 {
|
||||
fn from(badges: Vec<ChannelBadge>) -> Self {
|
||||
badges
|
||||
.first()
|
||||
.map_or(crate::model::Verification::None, |b| {
|
||||
match b.metadata_badge_renderer.style {
|
||||
ChannelBadgeStyle::BadgeStyleTypeVerified => Self::Verified,
|
||||
ChannelBadgeStyle::BadgeStyleTypeVerifiedArtist => Self::Artist,
|
||||
}
|
||||
})
|
||||
badges.get(0).map_or(crate::model::Verification::None, |b| {
|
||||
match b.metadata_badge_renderer.style {
|
||||
ChannelBadgeStyle::BadgeStyleTypeVerified => Self::Verified,
|
||||
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 {
|
||||
ExtractionError::NotFound {
|
||||
id: id.to_owned(),
|
||||
|
@ -496,196 +368,3 @@ pub(crate) fn alerts_to_err(id: &str, alerts: Option<Vec<Alert>>) -> ExtractionE
|
|||
.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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicArtist {
|
||||
pub contents: SingleColumnBrowseResult<Tab<SectionList<ItemSection>>>,
|
||||
pub contents: SingleColumnBrowseResult<Tab<Option<SectionList<ItemSection>>>>,
|
||||
pub header: Header,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
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 super::AlertRenderer;
|
||||
use super::ContentsRenderer;
|
||||
use super::TextBox;
|
||||
use super::{
|
||||
music_item::{ItemSection, PlaylistPanelRenderer},
|
||||
ContentRenderer,
|
||||
ContentRenderer, SectionList,
|
||||
};
|
||||
|
||||
/// Response model for YouTube Music track details
|
||||
|
@ -35,11 +36,9 @@ pub(crate) struct TabbedRenderer {
|
|||
pub watch_next_tabbed_results_renderer: TabbedRendererInner,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TabbedRendererInner {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub tabs: Vec<Tab>,
|
||||
}
|
||||
|
||||
|
@ -108,14 +107,14 @@ pub(crate) struct PlaylistPanel {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicLyrics {
|
||||
pub contents: ListOrMessage<LyricsSection>,
|
||||
pub contents: LyricsContents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum ListOrMessage<T> {
|
||||
SectionListRenderer(ContentsRenderer<T>),
|
||||
MessageRenderer(TextBox),
|
||||
pub(crate) struct LyricsContents {
|
||||
pub message_renderer: Option<AlertRenderer>,
|
||||
pub section_list_renderer: Option<ContentsRenderer<LyricsSection>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -137,14 +136,5 @@ pub(crate) struct LyricsRenderer {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicRelated {
|
||||
pub contents: ListOrMessage<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),
|
||||
}
|
||||
}
|
||||
pub contents: SectionList<ItemSection>,
|
||||
}
|
||||
|
|
|
@ -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_with::{serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::text::{AttributedText, Text, TextComponents};
|
||||
use crate::serializer::text::{Text, TextComponents};
|
||||
|
||||
use super::{
|
||||
music_item::{
|
||||
Button, ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicThumbnailRenderer,
|
||||
ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicThumbnailRenderer,
|
||||
SingleColumnBrowseResult,
|
||||
},
|
||||
url_endpoint::OnTapWrap,
|
||||
ContentsRenderer, SectionList, Tab,
|
||||
Tab,
|
||||
};
|
||||
|
||||
/// Response model for YouTube Music playlists and albums
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicPlaylist {
|
||||
pub contents: Contents,
|
||||
pub contents: SingleColumnBrowseResult<Tab<SectionList>>,
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PlSectionList {
|
||||
pub(crate) struct SectionList {
|
||||
/// Includes a continuation token for fetching recommendations
|
||||
pub section_list_renderer: MusicContentsRenderer<ItemSection>,
|
||||
}
|
||||
|
@ -48,7 +29,6 @@ pub(crate) struct PlSectionList {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Header {
|
||||
#[serde(alias = "musicResponsiveHeaderRenderer")]
|
||||
pub music_detail_header_renderer: HeaderRenderer,
|
||||
}
|
||||
|
||||
|
@ -68,13 +48,12 @@ pub(crate) struct HeaderRenderer {
|
|||
pub subtitle: TextComponents,
|
||||
/// Playlist/album description. May contain hashtags which are
|
||||
/// 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.
|
||||
/// Missing on artist_tracks view.
|
||||
#[serde(default)]
|
||||
pub thumbnail: MusicThumbnailRenderer,
|
||||
/// Channel (only on TwoColumnBrowseResultsRenderer)
|
||||
pub strapline_text_one: Option<TextComponents>,
|
||||
/// Number of tracks + playtime.
|
||||
/// Missing on artist_tracks view.
|
||||
///
|
||||
|
@ -84,32 +63,9 @@ pub(crate) struct HeaderRenderer {
|
|||
#[serde(default)]
|
||||
#[serde_as(as = "Text")]
|
||||
pub second_subtitle: Vec<String>,
|
||||
/// Channel (newer data model)
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub facepile: Option<AvatarStackViewModelWrap>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
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)]
|
||||
|
@ -124,53 +80,31 @@ pub(crate) struct HeaderMenu {
|
|||
pub(crate) struct HeaderMenuRenderer {
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub top_level_buttons: Vec<Button>,
|
||||
pub top_level_buttons: Vec<TopLevelButton>,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub items: Vec<MusicItemMenuEntry>,
|
||||
}
|
||||
|
||||
impl From<Description> for TextComponents {
|
||||
fn from(value: Description) -> Self {
|
||||
match value {
|
||||
Description::Text(v) => v,
|
||||
Description::Shelf {
|
||||
music_description_shelf_renderer,
|
||||
} => music_description_shelf_renderer.description,
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TopLevelButton {
|
||||
pub button_renderer: ButtonRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AvatarStackViewModelWrap {
|
||||
pub avatar_stack_view_model: AvatarStackViewModel,
|
||||
}
|
||||
|
||||
#[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,
|
||||
pub(crate) struct ButtonRenderer {
|
||||
pub navigation_endpoint: PlaylistEndpoint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AvatarStackRendererContext {
|
||||
pub command_context: Option<OnTapWrap>,
|
||||
pub(crate) struct PlaylistEndpoint {
|
||||
pub watch_playlist_endpoint: PlaylistWatchEndpoint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Microformat {
|
||||
pub microformat_data_renderer: MicroformatData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MicroformatData {
|
||||
pub url_canonical: String,
|
||||
pub(crate) struct PlaylistWatchEndpoint {
|
||||
pub playlist_id: String,
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@ use std::ops::Range;
|
|||
|
||||
use serde::Deserialize;
|
||||
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};
|
||||
|
||||
#[serde_as]
|
||||
|
@ -19,10 +19,6 @@ pub(crate) struct Player {
|
|||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub storyboards: Option<Storyboards>,
|
||||
pub response_context: ResponseContext,
|
||||
#[serde(default)]
|
||||
pub player_config: PlayerConfig,
|
||||
#[serde(default)]
|
||||
pub heartbeat_params: HeartbeatParams,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
@ -61,6 +57,9 @@ pub(crate) enum PlayabilityStatus {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct Empty {}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ErrorScreen {
|
||||
|
@ -79,7 +78,7 @@ pub(crate) struct ErrorMessage {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct StreamingData {
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub expires_in_seconds: u32,
|
||||
#[serde(default)]
|
||||
pub formats: MapResult<Vec<Format>>,
|
||||
|
@ -89,10 +88,6 @@ pub(crate) struct StreamingData {
|
|||
pub dash_manifest_url: Option<String>,
|
||||
/// Only on livestreams
|
||||
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]
|
||||
|
@ -111,7 +106,7 @@ pub(crate) struct Format {
|
|||
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub approx_duration_ms: Option<u32>,
|
||||
|
||||
#[serde_as(as = "Option<crate::serializer::Range>")]
|
||||
|
@ -119,7 +114,7 @@ pub(crate) struct Format {
|
|||
#[serde_as(as = "Option<crate::serializer::Range>")]
|
||||
pub init_range: Option<Range<u32>>,
|
||||
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub content_length: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
|
@ -134,23 +129,20 @@ pub(crate) struct Format {
|
|||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub audio_quality: Option<AudioQuality>,
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub audio_sample_rate: Option<u32>,
|
||||
pub audio_channels: Option<u8>,
|
||||
pub loudness_db: Option<f32>,
|
||||
pub audio_track: Option<AudioTrack>,
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
@ -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")]
|
||||
pub(crate) enum Quality {
|
||||
Tiny,
|
||||
|
@ -176,19 +168,17 @@ pub(crate) enum Quality {
|
|||
Hd2160,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub(crate) enum AudioQuality {
|
||||
#[serde(rename = "AUDIO_QUALITY_ULTRALOW")]
|
||||
UltraLow,
|
||||
#[serde(rename = "AUDIO_QUALITY_LOW")]
|
||||
#[serde(rename = "AUDIO_QUALITY_LOW", alias = "low")]
|
||||
Low,
|
||||
#[serde(rename = "AUDIO_QUALITY_MEDIUM")]
|
||||
#[serde(rename = "AUDIO_QUALITY_MEDIUM", alias = "medium")]
|
||||
Medium,
|
||||
#[serde(rename = "AUDIO_QUALITY_HIGH")]
|
||||
#[serde(rename = "AUDIO_QUALITY_HIGH", alias = "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")]
|
||||
pub(crate) enum FormatType {
|
||||
#[default]
|
||||
|
@ -203,7 +193,7 @@ pub(crate) struct ColorInfo {
|
|||
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")]
|
||||
pub(crate) enum Primaries {
|
||||
#[default]
|
||||
|
@ -211,24 +201,6 @@ pub(crate) enum Primaries {
|
|||
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)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub(crate) struct AudioTrack {
|
||||
|
@ -264,8 +236,8 @@ pub(crate) struct CaptionTrack {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct VideoDetails {
|
||||
pub video_id: String,
|
||||
pub title: Option<String>,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub title: String,
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub length_seconds: u32,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
@ -273,9 +245,9 @@ pub(crate) struct VideoDetails {
|
|||
pub short_description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub thumbnail: Thumbnails,
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
pub view_count: Option<u64>,
|
||||
pub author: Option<String>,
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub view_count: u64,
|
||||
pub author: String,
|
||||
pub is_live_content: bool,
|
||||
}
|
||||
|
||||
|
@ -290,57 +262,3 @@ pub(crate) struct Storyboards {
|
|||
pub(crate) struct StoryboardRenderer {
|
||||
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_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::{
|
||||
url_endpoint::OnTapWrap, video_item::YouTubeListRenderer, Alert, ContentRenderer,
|
||||
ContentsRenderer, ImageView, PageHeaderRendererContent, PhMetadataView, ResponseContext,
|
||||
SectionList, Tab, TextBox, ThumbnailsWrap, TwoColumnBrowseResults,
|
||||
video_item::YouTubeListRenderer, Alert, ContentsRenderer, ResponseContext, SectionList, Tab,
|
||||
ThumbnailsWrap, TwoColumnBrowseResults,
|
||||
};
|
||||
|
||||
#[serde_as]
|
||||
|
@ -30,15 +29,13 @@ pub(crate) struct ItemSection {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PlaylistVideoListRenderer {
|
||||
#[serde(alias = "richGridRenderer")]
|
||||
pub playlist_video_list_renderer: YouTubeListRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum Header {
|
||||
PlaylistHeaderRenderer(HeaderRenderer),
|
||||
PageHeaderRenderer(ContentRenderer<PageHeaderRendererContent<PageHeaderRendererInner>>),
|
||||
pub(crate) struct Header {
|
||||
pub playlist_header_renderer: HeaderRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
@ -70,7 +67,15 @@ pub(crate) struct PlaylistHeaderBanner {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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)]
|
||||
|
@ -89,7 +94,6 @@ pub(crate) struct SidebarItemPrimary {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SidebarPrimaryInfoRenderer {
|
||||
pub description: Option<TextComponents>,
|
||||
pub thumbnail_renderer: PlaylistThumbnailRenderer,
|
||||
/// - `"495", " videos"`
|
||||
/// - `"3,310,996 views"`
|
||||
|
@ -105,73 +109,3 @@ pub(crate) struct PlaylistThumbnailRenderer {
|
|||
#[serde(alias = "playlistCustomThumbnailRenderer")]
|
||||
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},
|
||||
Deserialize,
|
||||
};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
use serde_with::{json::JsonString, serde_as};
|
||||
|
||||
use super::{video_item::YouTubeListRendererWrap, ResponseContext};
|
||||
|
||||
|
@ -10,7 +10,7 @@ use super::{video_item::YouTubeListRendererWrap, ResponseContext};
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Search {
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub estimated_results: Option<u64>,
|
||||
pub contents: Contents,
|
||||
pub response_context: ResponseContext,
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, DefaultOnError};
|
||||
|
||||
use crate::{
|
||||
model::{TrackType, UrlTarget},
|
||||
util,
|
||||
};
|
||||
|
||||
use super::Empty;
|
||||
use crate::{model::UrlTarget, util};
|
||||
|
||||
/// navigation/resolve_url response model
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -16,30 +11,21 @@ pub(crate) struct ResolvedUrl {
|
|||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum NavigationEndpoint {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Watch {
|
||||
#[serde(alias = "reelWatchEndpoint")]
|
||||
watch_endpoint: WatchEndpoint,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Browse {
|
||||
browse_endpoint: BrowseEndpoint,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
command_metadata: Option<CommandMetadata>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Url { url_endpoint: UrlEndpoint },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
WatchPlaylist {
|
||||
watch_playlist_endpoint: WatchPlaylistEndpoint,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(unused)]
|
||||
CreatePlaylist { create_playlist_endpoint: Empty },
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NavigationEndpoint {
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub watch_endpoint: Option<WatchEndpoint>,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub browse_endpoint: Option<BrowseEndpoint>,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub url_endpoint: Option<UrlEndpoint>,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub command_metadata: Option<CommandMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -66,12 +52,6 @@ pub(crate) struct BrowseEndpointWrap {
|
|||
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 {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
|
@ -123,12 +103,9 @@ pub(crate) struct BrowseEndpointConfig {
|
|||
pub browse_endpoint_context_music_config: BrowseEndpointMusicConfig,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BrowseEndpointMusicConfig {
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub page_type: PageType,
|
||||
}
|
||||
|
||||
|
@ -138,12 +115,9 @@ pub(crate) struct CommandMetadata {
|
|||
pub web_command_metadata: WebCommandMetadata,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WebCommandMetadata {
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub web_page_type: PageType,
|
||||
}
|
||||
|
||||
|
@ -162,60 +136,24 @@ pub(crate) struct WatchEndpointConfig {
|
|||
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)]
|
||||
pub(crate) enum MusicVideoType {
|
||||
#[default]
|
||||
#[serde(rename = "MUSIC_VIDEO_TYPE_OMV", alias = "MUSIC_VIDEO_TYPE_UGC")]
|
||||
#[serde(rename = "MUSIC_VIDEO_TYPE_OMV")]
|
||||
Video,
|
||||
#[serde(rename = "MUSIC_VIDEO_TYPE_ATV")]
|
||||
Track,
|
||||
#[serde(rename = "MUSIC_VIDEO_TYPE_PODCAST_EPISODE")]
|
||||
Episode,
|
||||
}
|
||||
|
||||
impl MusicVideoType {
|
||||
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)]
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum PageType {
|
||||
#[serde(
|
||||
rename = "MUSIC_PAGE_TYPE_ARTIST",
|
||||
alias = "MUSIC_PAGE_TYPE_AUDIOBOOK_ARTIST"
|
||||
)]
|
||||
Artist,
|
||||
#[serde(rename = "MUSIC_PAGE_TYPE_ARTIST_DISCOGRAPHY")]
|
||||
ArtistDiscography,
|
||||
#[serde(rename = "MUSIC_PAGE_TYPE_ALBUM", alias = "MUSIC_PAGE_TYPE_AUDIOBOOK")]
|
||||
Album,
|
||||
#[serde(
|
||||
|
@ -225,11 +163,7 @@ pub(crate) enum PageType {
|
|||
Channel,
|
||||
#[serde(rename = "MUSIC_PAGE_TYPE_PLAYLIST", alias = "WEB_PAGE_TYPE_PLAYLIST")]
|
||||
Playlist,
|
||||
#[serde(rename = "MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE")]
|
||||
Podcast,
|
||||
#[serde(rename = "MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE")]
|
||||
Episode,
|
||||
#[default]
|
||||
#[serde(rename = "MUSIC_PAGE_TYPE_UNKNOWN")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
|
@ -237,15 +171,11 @@ impl PageType {
|
|||
pub(crate) fn to_url_target(self, id: String) -> Option<UrlTarget> {
|
||||
match self {
|
||||
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::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,
|
||||
}
|
||||
}
|
||||
|
@ -255,9 +185,9 @@ impl PageType {
|
|||
pub(crate) enum MusicPageType {
|
||||
Artist,
|
||||
Album,
|
||||
Playlist { is_podcast: bool },
|
||||
Track { vtype: MusicVideoType },
|
||||
User,
|
||||
Playlist,
|
||||
Track { is_video: bool },
|
||||
Unknown,
|
||||
None,
|
||||
}
|
||||
|
||||
|
@ -266,131 +196,46 @@ impl From<PageType> for MusicPageType {
|
|||
match t {
|
||||
PageType::Artist => MusicPageType::Artist,
|
||||
PageType::Album => MusicPageType::Album,
|
||||
PageType::Playlist => MusicPageType::Playlist { is_podcast: false },
|
||||
PageType::Podcast => MusicPageType::Playlist { is_podcast: true },
|
||||
PageType::Channel => MusicPageType::User,
|
||||
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(),
|
||||
PageType::Playlist => MusicPageType::Playlist,
|
||||
PageType::Channel | PageType::ArtistDiscography => MusicPageType::None,
|
||||
PageType::Unknown => MusicPageType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NavigationEndpoint {
|
||||
/// Get the YouTube Music page and id from a browse/watch endpoint
|
||||
pub(crate) fn music_page(self) -> Option<MusicPage> {
|
||||
match self {
|
||||
NavigationEndpoint::Watch { watch_endpoint } => {
|
||||
if watch_endpoint
|
||||
.playlist_id
|
||||
.map(|plid| plid.starts_with("RDQM"))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
// Genre radios (e.g. "pop radio") will be skipped
|
||||
Some(MusicPage {
|
||||
id: watch_endpoint.video_id,
|
||||
typ: MusicPageType::None,
|
||||
})
|
||||
} else {
|
||||
Some(MusicPage {
|
||||
id: watch_endpoint.video_id,
|
||||
typ: MusicPageType::Track {
|
||||
vtype: watch_endpoint
|
||||
.watch_endpoint_music_supported_configs
|
||||
.watch_endpoint_music_config
|
||||
.music_video_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
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,
|
||||
pub(crate) fn music_page(self) -> Option<(MusicPageType, String)> {
|
||||
self.browse_endpoint
|
||||
.and_then(|be| {
|
||||
be.browse_endpoint_context_supported_configs.map(|config| {
|
||||
(
|
||||
config.browse_endpoint_context_music_config.page_type.into(),
|
||||
be.browse_id,
|
||||
)
|
||||
}),
|
||||
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)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.watch_endpoint.map(|watch| {
|
||||
if watch
|
||||
.playlist_id
|
||||
.map(|plid| plid.starts_with("RDQM"))
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
NavigationEndpoint::Url { .. } => None,
|
||||
NavigationEndpoint::WatchPlaylist {
|
||||
watch_playlist_endpoint,
|
||||
} => Some(watch_playlist_endpoint.playlist_id),
|
||||
NavigationEndpoint::CreatePlaylist { .. } => None,
|
||||
}
|
||||
{
|
||||
// Genre radios (e.g. "pop radio") will be skipped
|
||||
(MusicPageType::None, watch.video_id)
|
||||
} else {
|
||||
(
|
||||
MusicPageType::Track {
|
||||
is_video: watch
|
||||
.watch_endpoint_music_supported_configs
|
||||
.watch_endpoint_music_config
|
||||
.music_video_type
|
||||
== MusicVideoType::Video,
|
||||
},
|
||||
watch.video_id,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,9 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::text::TextComponent;
|
||||
use crate::serializer::{
|
||||
text::{AccessibilityText, AttributedText, Text, TextComponent, TextComponents},
|
||||
text::{AccessibilityText, AttributedText, Text, TextComponents},
|
||||
MapResult,
|
||||
};
|
||||
|
||||
|
@ -12,10 +13,7 @@ use super::{
|
|||
url_endpoint::BrowseEndpointWrap, ContinuationEndpoint, ContinuationItemRenderer, Icon,
|
||||
MusicContinuationData, Thumbnails,
|
||||
};
|
||||
use super::{
|
||||
ChannelBadge, ContentsRendererLogged, FrameworkUpdates, ImageView, ResponseContext,
|
||||
YouTubeListItem,
|
||||
};
|
||||
use super::{ChannelBadge, ContentsRendererLogged, ResponseContext, YouTubeListItem};
|
||||
|
||||
/*
|
||||
#VIDEO DETAILS
|
||||
|
@ -79,8 +77,8 @@ pub(crate) enum VideoResultsItem {
|
|||
/// Like/Dislike button
|
||||
video_actions: VideoActions,
|
||||
/// Absolute textual date (e.g. `Dec 29, 2019`)
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
date_text: Option<String>,
|
||||
#[serde_as(as = "Text")]
|
||||
date_text: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
VideoSecondaryInfoRenderer {
|
||||
|
@ -149,46 +147,6 @@ pub(crate) enum TopLevelButton {
|
|||
SegmentedLikeDislikeButtonRenderer {
|
||||
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
|
||||
|
@ -478,7 +436,6 @@ pub(crate) struct VideoComments {
|
|||
/// - n*commentRenderer, continuationItemRenderer:
|
||||
/// replies + continuation
|
||||
pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>,
|
||||
pub framework_updates: Option<FrameworkUpdates<Payload>>,
|
||||
}
|
||||
|
||||
/// Video comments continuation
|
||||
|
@ -501,13 +458,23 @@ pub(crate) struct AppendComments {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum CommentListItem {
|
||||
/// 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
|
||||
CommentRenderer(CommentRenderer),
|
||||
/// Reply comment (A/B #14)
|
||||
CommentViewModel(CommentViewModel),
|
||||
/// 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)
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Comment {
|
||||
|
@ -597,7 +524,7 @@ pub(crate) struct CommentRenderer {
|
|||
pub action_buttons: CommentActionButtons,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug, Deserialize)]
|
||||
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub(crate) enum CommentPriority {
|
||||
/// Default rendering priority
|
||||
|
@ -607,27 +534,6 @@ pub(crate) enum CommentPriority {
|
|||
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
|
||||
/// for fetching them.
|
||||
#[derive(Default, Debug, Deserialize)]
|
||||
|
@ -691,107 +597,3 @@ pub(crate) struct AuthorCommentBadgeRenderer {
|
|||
/// Artist: `OFFICIAL_ARTIST_BADGE`
|
||||
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_with::{
|
||||
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
|
||||
json::JsonString, rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails};
|
||||
use super::{url_endpoint::NavigationEndpoint, ChannelBadge, ContinuationEndpoint, Thumbnails};
|
||||
use crate::{
|
||||
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
|
||||
model::{
|
||||
Channel, ChannelId, ChannelInfo, ChannelItem, ChannelTag, PlaylistItem, Verification,
|
||||
VideoItem, YouTubeItem,
|
||||
},
|
||||
param::Language,
|
||||
serializer::{
|
||||
text::{AttributedText, Text, TextComponent},
|
||||
text::{AccessibilityText, Text, TextComponent},
|
||||
MapResult,
|
||||
},
|
||||
util::{self, timeago, TryRemove},
|
||||
};
|
||||
|
||||
#[cfg(feature = "userdata")]
|
||||
use crate::{client::response::SimpleHeaderRenderer, model::HistoryItem};
|
||||
#[cfg(feature = "userdata")]
|
||||
use time::UtcOffset;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
@ -27,7 +27,6 @@ pub(crate) enum YouTubeListItem {
|
|||
#[serde(alias = "gridVideoRenderer", alias = "compactVideoRenderer")]
|
||||
VideoRenderer(VideoRenderer),
|
||||
ReelItemRenderer(ReelItemRenderer),
|
||||
ShortsLockupViewModel(ShortsLockupViewModel),
|
||||
PlaylistVideoRenderer(PlaylistVideoRenderer),
|
||||
|
||||
#[serde(alias = "gridPlaylistRenderer")]
|
||||
|
@ -35,8 +34,6 @@ pub(crate) enum YouTubeListItem {
|
|||
|
||||
ChannelRenderer(ChannelRenderer),
|
||||
|
||||
LockupViewModel(LockupViewModel),
|
||||
|
||||
/// Continauation items are located at the end of a list
|
||||
/// and contain the continuation token for progressive loading
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
@ -51,6 +48,9 @@ pub(crate) enum YouTubeListItem {
|
|||
corrected_query: String,
|
||||
},
|
||||
|
||||
/// Channel metadata (about tab)
|
||||
ChannelAboutFullMetadataRenderer(ChannelFullMetadata),
|
||||
|
||||
/// Contains video on startpage
|
||||
///
|
||||
/// 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
|
||||
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
|
||||
ItemSectionRenderer {
|
||||
#[cfg(feature = "userdata")]
|
||||
header: Option<ItemSectionHeader>,
|
||||
#[serde(alias = "items")]
|
||||
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
|
||||
///
|
||||
/// Unimplemented:
|
||||
|
@ -144,71 +134,18 @@ pub(crate) struct ReelItemRenderer {
|
|||
/// Contains `No views` if the view count is zero
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
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_as(as = "DefaultOnError")]
|
||||
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
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -220,7 +157,7 @@ pub(crate) struct PlaylistVideoRenderer {
|
|||
pub title: String,
|
||||
#[serde(rename = "shortBylineText")]
|
||||
pub channel: TextComponent,
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub length_seconds: Option<u32>,
|
||||
/// Regular video: `["29K views", " • ", "13 years ago"]`
|
||||
/// Livestream: `["66K", " watching"]`
|
||||
|
@ -250,7 +187,7 @@ pub(crate) struct PlaylistRenderer {
|
|||
/// The first item of this list contains the playlist thumbnail,
|
||||
/// subsequent items contain very small thumbnails of the next playlist videos
|
||||
pub thumbnails: Option<Vec<Thumbnails>>,
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub video_count: Option<u64>,
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
pub video_count_short_text: Option<String>,
|
||||
|
@ -301,19 +238,12 @@ pub(crate) struct YouTubeListRenderer {
|
|||
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]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpcomingEventData {
|
||||
/// Unixtime in seconds
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub start_time: i64,
|
||||
}
|
||||
|
||||
|
@ -428,6 +358,28 @@ pub(crate) struct ReelPlayerHeaderRenderer {
|
|||
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 {
|
||||
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
|
||||
/// (videos, channels, playlists)
|
||||
#[derive(Debug)]
|
||||
|
@ -471,6 +427,7 @@ pub(crate) struct YouTubeListMapper<T> {
|
|||
pub warnings: Vec<String>,
|
||||
pub ctoken: Option<String>,
|
||||
pub corrected_query: Option<String>,
|
||||
pub channel_info: Option<ChannelInfo>,
|
||||
}
|
||||
|
||||
impl<T> YouTubeListMapper<T> {
|
||||
|
@ -482,6 +439,7 @@ impl<T> YouTubeListMapper<T> {
|
|||
warnings: Vec::new(),
|
||||
ctoken: None,
|
||||
corrected_query: None,
|
||||
channel_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -499,6 +457,7 @@ impl<T> YouTubeListMapper<T> {
|
|||
warnings,
|
||||
ctoken: None,
|
||||
corrected_query: None,
|
||||
channel_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -519,22 +478,23 @@ impl<T> YouTubeListMapper<T> {
|
|||
VideoItem {
|
||||
id: video.video_id,
|
||||
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(),
|
||||
channel: video
|
||||
.channel
|
||||
.and_then(|c| ChannelTag::try_from(c).ok())
|
||||
.map(|mut c| {
|
||||
c.avatar = video
|
||||
.channel_thumbnail_supported_renderers
|
||||
.map(|tn| tn.channel_thumbnail_with_link_renderer.thumbnail)
|
||||
.or(video.channel_thumbnail)
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
if !c.verification.verified() {
|
||||
c.verification = video.owner_badges.into();
|
||||
}
|
||||
c
|
||||
.and_then(|c| {
|
||||
ChannelId::try_from(c).ok().map(|c| ChannelTag {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
avatar: video
|
||||
.channel_thumbnail_supported_renderers
|
||||
.map(|tn| tn.channel_thumbnail_with_link_renderer.thumbnail)
|
||||
.or(video.channel_thumbnail)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
verification: video.owner_badges.into(),
|
||||
subscriber_count: None,
|
||||
})
|
||||
})
|
||||
.or_else(|| self.channel.clone()),
|
||||
publish_date: video
|
||||
|
@ -570,10 +530,29 @@ impl<T> YouTubeListMapper<T> {
|
|||
.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 {
|
||||
id: video.video_id,
|
||||
name: video.headline,
|
||||
duration: None,
|
||||
length,
|
||||
thumbnail: video.thumbnail.into(),
|
||||
channel: self.channel.clone(),
|
||||
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 {
|
||||
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 video_info1 = video_info
|
||||
.next()
|
||||
|
@ -653,7 +616,7 @@ impl<T> YouTubeListMapper<T> {
|
|||
VideoItem {
|
||||
id: video.video_id,
|
||||
name: video.title,
|
||||
duration: video.length_seconds,
|
||||
length: video.length_seconds,
|
||||
thumbnail: video.thumbnail.into(),
|
||||
channel,
|
||||
publish_date,
|
||||
|
@ -679,12 +642,14 @@ impl<T> YouTubeListMapper<T> {
|
|||
.into(),
|
||||
channel: playlist
|
||||
.channel
|
||||
.and_then(|c| ChannelTag::try_from(c).ok())
|
||||
.map(|mut c| {
|
||||
if !c.verification.verified() {
|
||||
c.verification = playlist.owner_badges.into();
|
||||
}
|
||||
c
|
||||
.and_then(|c| {
|
||||
ChannelId::try_from(c).ok().map(|c| ChannelTag {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
avatar: Vec::new(),
|
||||
verification: playlist.owner_badges.into(),
|
||||
subscriber_count: None,
|
||||
})
|
||||
})
|
||||
.or_else(|| self.channel.clone()),
|
||||
video_count: playlist.video_count.or_else(|| {
|
||||
|
@ -697,112 +662,31 @@ impl<T> YouTubeListMapper<T> {
|
|||
|
||||
fn map_channel(&mut self, channel: ChannelRenderer) -> ChannelItem {
|
||||
// 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
|
||||
.as_ref()
|
||||
.map(|txt| txt.starts_with('@'))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
(channel.subscriber_count_text, channel.video_count_text)
|
||||
(channel.video_count_text, None)
|
||||
} else {
|
||||
(None, channel.subscriber_count_text)
|
||||
(channel.subscriber_count_text, channel.video_count_text)
|
||||
};
|
||||
|
||||
ChannelItem {
|
||||
id: channel.channel_id,
|
||||
name: channel.title,
|
||||
handle,
|
||||
avatar: channel.thumbnail.into(),
|
||||
verification: channel.owner_badges.into(),
|
||||
subscriber_count: sc_txt.and_then(|txt| {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
|
@ -812,11 +696,6 @@ impl YouTubeListMapper<YouTubeItem> {
|
|||
let mapped = YouTubeItem::Video(self.map_video(video));
|
||||
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) => {
|
||||
let mapped = self.map_short_video(video);
|
||||
self.items.push(YouTubeItem::Video(mapped));
|
||||
|
@ -833,25 +712,42 @@ impl YouTubeListMapper<YouTubeItem> {
|
|||
let mapped = YouTubeItem::Channel(self.map_channel(channel));
|
||||
self.items.push(mapped);
|
||||
}
|
||||
YouTubeListItem::LockupViewModel(lockup) => {
|
||||
if let Some(mapped) = self.map_lockup(lockup) {
|
||||
self.items.push(mapped);
|
||||
}
|
||||
}
|
||||
YouTubeListItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => self.ctoken = Some(continuation_endpoint.continuation_command.token),
|
||||
YouTubeListItem::ShowingResultsForRenderer { 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 } => {
|
||||
self.map_item(*content);
|
||||
}
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
||||
self.warnings.append(&mut contents.warnings);
|
||||
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);
|
||||
self.items.push(mapped);
|
||||
}
|
||||
YouTubeListItem::ShortsLockupViewModel(video) => {
|
||||
if let Some(mapped) = self.map_short_video2(video) {
|
||||
self.items.push(mapped);
|
||||
}
|
||||
}
|
||||
YouTubeListItem::PlaylistVideoRenderer(video) => {
|
||||
let mapped = self.map_playlist_video(video);
|
||||
self.items.push(mapped);
|
||||
}
|
||||
YouTubeListItem::LockupViewModel(lockup) => {
|
||||
if let Some(YouTubeItem::Video(mapped)) = self.map_lockup(lockup) {
|
||||
self.items.push(mapped);
|
||||
}
|
||||
}
|
||||
YouTubeListItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => self.ctoken = Some(continuation_endpoint.continuation_command.token),
|
||||
|
@ -895,7 +781,7 @@ impl YouTubeListMapper<VideoItem> {
|
|||
YouTubeListItem::RichItemRenderer { content } => {
|
||||
self.map_item(*content);
|
||||
}
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
||||
self.warnings.append(&mut contents.warnings);
|
||||
contents.c.into_iter().for_each(|it| self.map_item(it));
|
||||
}
|
||||
|
@ -907,23 +793,6 @@ impl YouTubeListMapper<VideoItem> {
|
|||
self.warnings.append(&mut res.warnings);
|
||||
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> {
|
||||
|
@ -933,11 +802,6 @@ impl YouTubeListMapper<PlaylistItem> {
|
|||
let mapped = self.map_playlist(playlist);
|
||||
self.items.push(mapped);
|
||||
}
|
||||
YouTubeListItem::LockupViewModel(lockup) => {
|
||||
if let Some(YouTubeItem::Playlist(mapped)) = self.map_lockup(lockup) {
|
||||
self.items.push(mapped);
|
||||
}
|
||||
}
|
||||
YouTubeListItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => self.ctoken = Some(continuation_endpoint.continuation_command.token),
|
||||
|
@ -947,7 +811,7 @@ impl YouTubeListMapper<PlaylistItem> {
|
|||
YouTubeListItem::RichItemRenderer { content } => {
|
||||
self.map_item(*content);
|
||||
}
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
||||
self.warnings.append(&mut contents.warnings);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
#[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 crate::{
|
||||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
traits::FromYtItem,
|
||||
SearchResult, YouTubeItem,
|
||||
},
|
||||
model::{paginator::Paginator, SearchResult, YouTubeItem},
|
||||
param::search_filter::SearchFilter,
|
||||
};
|
||||
|
||||
use super::{response, ClientType, MapRespCtx, MapResponse, MapResult, RustyPipeQuery};
|
||||
use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QSearch<'a> {
|
||||
context: YTContext<'a>,
|
||||
query: &'a str,
|
||||
params: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<String>,
|
||||
}
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// Search YouTube
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn search<T: FromYtItem, S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
query: S,
|
||||
) -> Result<SearchResult<T>, Error> {
|
||||
pub async fn search<S: AsRef<str>>(&self, query: S) -> Result<SearchResult, Error> {
|
||||
let query = query.as_ref();
|
||||
let context = self.get_context(ClientType::Desktop, true, None).await;
|
||||
let request_body = QSearch {
|
||||
context,
|
||||
query,
|
||||
params: "8AEB",
|
||||
params: None,
|
||||
};
|
||||
|
||||
self.execute_request::<response::Search, _, _>(
|
||||
|
@ -45,16 +39,17 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Search YouTube using the given [`SearchFilter`]
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn search_filter<T: FromYtItem, S: AsRef<str> + Debug>(
|
||||
pub async fn search_filter<S: AsRef<str>>(
|
||||
&self,
|
||||
query: S,
|
||||
filter: &SearchFilter,
|
||||
) -> Result<SearchResult<T>, Error> {
|
||||
) -> Result<SearchResult, Error> {
|
||||
let query = query.as_ref();
|
||||
let context = self.get_context(ClientType::Desktop, true, None).await;
|
||||
let request_body = QSearch {
|
||||
context,
|
||||
query,
|
||||
params: &filter.encode(),
|
||||
params: Some(filter.encode()),
|
||||
};
|
||||
|
||||
self.execute_request::<response::Search, _, _>(
|
||||
|
@ -68,11 +63,7 @@ impl RustyPipeQuery {
|
|||
}
|
||||
|
||||
/// Get YouTube search suggestions
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn search_suggestion<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
query: S,
|
||||
) -> Result<Vec<String>, Error> {
|
||||
pub async fn search_suggestion<S: AsRef<str>>(&self, query: S) -> Result<Vec<String>, Error> {
|
||||
let url = url::Url::parse_with_params(
|
||||
"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(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<SearchResult<T>>, ExtractionError> {
|
||||
_id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||
) -> Result<MapResult<SearchResult>, ExtractionError> {
|
||||
let items = self
|
||||
.contents
|
||||
.two_column_search_results_renderer
|
||||
|
@ -107,28 +100,20 @@ impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search {
|
|||
.section_list_renderer
|
||||
.contents;
|
||||
|
||||
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
|
||||
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
|
||||
mapper.map_response(items);
|
||||
|
||||
Ok(MapResult {
|
||||
c: SearchResult {
|
||||
items: Paginator::new_ext(
|
||||
self.estimated_results,
|
||||
mapper
|
||||
.items
|
||||
.into_iter()
|
||||
.filter_map(T::from_yt_item)
|
||||
.collect(),
|
||||
mapper.items,
|
||||
mapper.ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::Search,
|
||||
false,
|
||||
None,
|
||||
crate::model::paginator::ContinuationEndpoint::Search,
|
||||
),
|
||||
corrected_query: mapper.corrected_query,
|
||||
visitor_data: self
|
||||
.response_context
|
||||
.visitor_data
|
||||
.or_else(|| ctx.visitor_data.map(str::to_owned)),
|
||||
visitor_data: self.response_context.visitor_data,
|
||||
},
|
||||
warnings: mapper.warnings,
|
||||
})
|
||||
|
@ -143,8 +128,9 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use crate::{
|
||||
client::{response, MapRespCtx, MapResponse},
|
||||
model::{SearchResult, YouTubeItem},
|
||||
client::{response, MapResponse},
|
||||
model::SearchResult,
|
||||
param::Language,
|
||||
serializer::MapResult,
|
||||
util::tests::TESTFILES,
|
||||
};
|
||||
|
@ -159,8 +145,7 @@ mod tests {
|
|||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let search: response::Search = serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<SearchResult<YouTubeItem>> =
|
||||
search.map_response(&MapRespCtx::test("")).unwrap();
|
||||
let map_res: MapResult<SearchResult> = search.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
|
|
|
@ -2,28 +2,166 @@
|
|||
source: src/client/channel.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
ChannelInfo(
|
||||
Channel(
|
||||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
url: "http://www.youtube.com/@EEVblog",
|
||||
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),
|
||||
video_count: Some(1920),
|
||||
create_date: Some("2009-04-04"),
|
||||
view_count: Some(199087682),
|
||||
country: Some(AU),
|
||||
links: [
|
||||
("EEVblog Web Site", "http://www.eevblog.com/"),
|
||||
("Twitter", "http://www.twitter.com/eevblog"),
|
||||
("Facebook", "http://www.facebook.com/EEVblog"),
|
||||
("EEVdiscover", "https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ"),
|
||||
("The EEVblog Forum", "http://www.eevblog.com/forum"),
|
||||
("EEVblog Merchandise (T-Shirts)", "http://www.eevblog.com/merch"),
|
||||
("EEVblog Donations", "http://www.eevblog.com/donations/"),
|
||||
("Patreon", "https://www.patreon.com/eevblog"),
|
||||
("SubscribeStar", "https://www.subscribestar.com/eevblog"),
|
||||
("The AmpHour Radio Show", "http://www.theamphour.com/"),
|
||||
("Flickr", "http://www.flickr.com/photos/eevblog"),
|
||||
("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"),
|
||||
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",
|
||||
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",
|
||||
],
|
||||
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"),
|
||||
view_count: Some(186854342),
|
||||
links: [
|
||||
("EEVblog Web Site", "http://www.eevblog.com/"),
|
||||
("Twitter", "http://www.twitter.com/eevblog"),
|
||||
("Facebook", "http://www.facebook.com/EEVblog"),
|
||||
("EEVdiscover", "https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ"),
|
||||
("The EEVblog Forum", "http://www.eevblog.com/forum"),
|
||||
("EEVblog Merchandise (T-Shirts)", "http://www.eevblog.com/merch"),
|
||||
("EEVblog Donations", "http://www.eevblog.com/donations/"),
|
||||
("Patreon", "https://www.patreon.com/eevblog"),
|
||||
("SubscribeStar", "https://www.subscribestar.com/eevblog"),
|
||||
("The AmpHour Radio Show", "http://www.theamphour.com/"),
|
||||
("Flickr", "http://www.flickr.com/photos/eevblog"),
|
||||
("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"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
|
@ -5,9 +5,7 @@ expression: map_res.c
|
|||
Channel(
|
||||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
handle: None,
|
||||
subscriber_count: Some(884000),
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
|
||||
|
@ -25,7 +23,7 @@ Channel(
|
|||
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",
|
||||
tags: [
|
||||
"electronics",
|
||||
|
@ -57,6 +55,7 @@ Channel(
|
|||
"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",
|
||||
|
@ -89,6 +88,60 @@ Channel(
|
|||
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: true,
|
||||
visitor_data: None,
|
||||
|
@ -98,7 +151,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "hhs95CI6Dsg",
|
||||
name: "MARS 2020 Landing LIVE",
|
||||
duration: Some(6321),
|
||||
length: Some(6321),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/hhs95CI6Dsg/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHUBoAC4AOKAgwIABABGGUgZShlMA8=&rs=AOn4CLAlPp2e1tF8gyf1cJisZGTMleissg",
|
||||
|
@ -125,7 +178,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -139,7 +192,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "cpQk2n-wmQ4",
|
||||
name: "LIVE Soldering",
|
||||
duration: Some(7046),
|
||||
length: Some(7046),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/cpQk2n-wmQ4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCoS3qwdY2rDbhkWJOWHisORlMKnA",
|
||||
|
@ -166,7 +219,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -180,7 +233,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "kIDV_XN9oA8",
|
||||
name: "LIVE Soldering",
|
||||
duration: Some(4353),
|
||||
length: Some(4353),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kIDV_XN9oA8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBG3KVoFpBFIYCG2mrox_kEq6Arug",
|
||||
|
@ -207,7 +260,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -221,7 +274,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "DWS4Qp3Yn0A",
|
||||
name: "Apollo 11 Launch LIVE - 50 Years Later",
|
||||
duration: Some(4560),
|
||||
length: Some(4560),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/DWS4Qp3Yn0A/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAFkIQ4er8qDNMlD9H8lPzfSnE99g",
|
||||
|
@ -248,7 +301,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -262,7 +315,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "LwjTe3SiVXg",
|
||||
name: "EEVblog LIVE Q&A",
|
||||
duration: Some(3943),
|
||||
length: Some(3943),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/LwjTe3SiVXg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzTlnjBJLT3KJVN4teMlX_svuaNA",
|
||||
|
@ -289,7 +342,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -303,7 +356,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "skPiz3GrVNs",
|
||||
name: "LIVE Keysight Scope Draw #2",
|
||||
duration: Some(2445),
|
||||
length: Some(2445),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/skPiz3GrVNs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFiIfUBfoL0Q9CLR9Pc8bXy-zclg",
|
||||
|
@ -330,7 +383,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -344,7 +397,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "HZc-Ctvgv5Y",
|
||||
name: "LIVE Keysight Scope Draw",
|
||||
duration: Some(6455),
|
||||
length: Some(6455),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/HZc-Ctvgv5Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQM1_QPh6u5_BFonLCdFPz-AcpkQ",
|
||||
|
@ -371,7 +424,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -385,7 +438,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "5ilODYy2zGE",
|
||||
name: "Ask Dave LIVE - March 8th 2019",
|
||||
duration: Some(10645),
|
||||
length: Some(10645),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/5ilODYy2zGE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCft4f7Lw3l3_u55bzUibWXr-UHTQ",
|
||||
|
@ -412,7 +465,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -426,7 +479,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "gQ7TTuiDH1M",
|
||||
name: "Ask Dave LIVE - Jan 28th 2019",
|
||||
duration: Some(17228),
|
||||
length: Some(17228),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/gQ7TTuiDH1M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAUPZz1xzckl5xzdBRonA_1WNWIyg",
|
||||
|
@ -453,7 +506,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -467,7 +520,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "qpw9dKxL2Ho",
|
||||
name: "LIVE KiCAD 5 PCB Design",
|
||||
duration: Some(8003),
|
||||
length: Some(8003),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/qpw9dKxL2Ho/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAC-kI2770I7JgVCTYExG0vXoYoxA",
|
||||
|
@ -494,7 +547,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -508,7 +561,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "wECZoUNd2GY",
|
||||
name: "EEVblog LIVE DIY TTL Computer Build",
|
||||
duration: Some(14599),
|
||||
length: Some(14599),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/wECZoUNd2GY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDzZwAD6bQQEaYuZEzmQ0sgQKc1yA",
|
||||
|
@ -535,7 +588,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -549,7 +602,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "bV99dn-tWDk",
|
||||
name: "EEVblog LIVE Scope Draw",
|
||||
duration: Some(2694),
|
||||
length: Some(2694),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/bV99dn-tWDk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAR4ckJxAituVMFCyWpYhHXozqQRA",
|
||||
|
@ -576,7 +629,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -590,7 +643,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "-NGRIFiu_p0",
|
||||
name: "EEVblog LIVE SHOW - End of 2017",
|
||||
duration: Some(12238),
|
||||
length: Some(12238),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/-NGRIFiu_p0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjMmIdgjiSMBQ2X73h6-NtVUIqSg",
|
||||
|
@ -617,7 +670,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -631,7 +684,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "zgE6_x4rM5k",
|
||||
name: "LIVE Show Giveaway",
|
||||
duration: Some(5533),
|
||||
length: Some(5533),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/zgE6_x4rM5k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjb92wUNqOvTKs9TCLCThvdkdz3A",
|
||||
|
@ -658,7 +711,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -672,7 +725,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "9DjABCJN2M8",
|
||||
name: "LIVE Testing of the Batteriser",
|
||||
duration: Some(10747),
|
||||
length: Some(10747),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/9DjABCJN2M8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBXhnnHCuNfSzHZC64KFsfHPPJDNg",
|
||||
|
@ -699,7 +752,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -713,7 +766,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "cAsUI2YhqN4",
|
||||
name: "LIVE Unboxing of the Batteriser! (Batteroo)",
|
||||
duration: Some(3102),
|
||||
length: Some(3102),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/cAsUI2YhqN4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCOE1MyG1nFXs9D2qdK78bpN1mc_g",
|
||||
|
@ -740,7 +793,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -754,7 +807,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "CLYKwFMW9J0",
|
||||
name: "Juno Live Again",
|
||||
duration: Some(811),
|
||||
length: Some(811),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/CLYKwFMW9J0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC7WO4HX0e7M58ddoJD5dkVjdKHYQ",
|
||||
|
@ -781,7 +834,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -795,7 +848,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "nV43vM9VcUA",
|
||||
name: "Juno Live",
|
||||
duration: Some(190),
|
||||
length: Some(190),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/nV43vM9VcUA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy-zEVPDvomCCi8YoP8Ig_Hrhzfw",
|
||||
|
@ -822,7 +875,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -836,7 +889,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "38uFiWzcDnc",
|
||||
name: "Juno Orbital Insertion Live",
|
||||
duration: Some(1731),
|
||||
length: Some(1731),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/38uFiWzcDnc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLALhrDygxFH4T2c-4efZqVaJnYY7g",
|
||||
|
@ -863,7 +916,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -877,7 +930,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "ib80yjc9VlM",
|
||||
name: "Juno Jupiter Live",
|
||||
duration: Some(581),
|
||||
length: Some(581),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/ib80yjc9VlM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDbJJvzoEmwUc7nAm6GLJpoZJKmgQ",
|
||||
|
@ -904,7 +957,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -918,7 +971,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "rQRakYpb8-g",
|
||||
name: "eevSTREAM: Lab Rearrangement Part 2",
|
||||
duration: Some(8616),
|
||||
length: Some(8616),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/rQRakYpb8-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAdGJH0yhCQ7kmI3d3JXVv_7xzJAQ",
|
||||
|
@ -945,7 +998,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -959,7 +1012,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "DwLEFKu2XWg",
|
||||
name: "eevSTREAM: Lab Rearrangement Part 1",
|
||||
duration: Some(768),
|
||||
length: Some(768),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/DwLEFKu2XWg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCXvSePgZ8NIKQTviqWvROVZFRPpA",
|
||||
|
@ -986,7 +1039,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1000,7 +1053,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "VeUDXQR3F2o",
|
||||
name: "Live Show",
|
||||
duration: Some(10360),
|
||||
length: Some(10360),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/VeUDXQR3F2o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDmgrfQXMTaGMahuP8F_UHJAomFbg",
|
||||
|
@ -1027,7 +1080,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1041,7 +1094,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "PgZx25vVwoI",
|
||||
name: "Live Giveaway",
|
||||
duration: Some(1808),
|
||||
length: Some(1808),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/PgZx25vVwoI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDTrMmoCfISxG0YSqC4oEyKGHdK_A",
|
||||
|
@ -1068,7 +1121,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1082,7 +1135,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "jUtzoO-ur34",
|
||||
name: "Inventables X-Carve LIVE Build Part 4",
|
||||
duration: Some(10665),
|
||||
length: Some(10665),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/jUtzoO-ur34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCO35sFP8D_Q08HxMZkNHFO8MmpDg",
|
||||
|
@ -1109,7 +1162,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1123,7 +1176,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "199gtbX1y4M",
|
||||
name: "Inventables X-Carve LIVE Build Part 3 + Batteriser Rant",
|
||||
duration: Some(6267),
|
||||
length: Some(6267),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/199gtbX1y4M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg3bMS00xpSXmNn1f5hXu_jWWC1w",
|
||||
|
@ -1150,7 +1203,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1164,7 +1217,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "nQH4I_p7-MI",
|
||||
name: "Inventables X-Carve LIVE Build Part 2",
|
||||
duration: Some(17643),
|
||||
length: Some(17643),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/nQH4I_p7-MI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBMIA1YzQefFwGj5UFikXuYS2Nkng",
|
||||
|
@ -1191,7 +1244,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1205,7 +1258,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "XBMNFXGKpaw",
|
||||
name: "Inventables X-Carve LIVE Build",
|
||||
duration: Some(5479),
|
||||
length: Some(5479),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/XBMNFXGKpaw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCV980wWO8tdx0aFDXwPn9aBQ2xlA",
|
||||
|
@ -1232,7 +1285,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1246,7 +1299,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "yl6DGgiE3J8",
|
||||
name: "Apollo Saturn LVDC Live testing",
|
||||
duration: Some(1076),
|
||||
length: Some(1076),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/yl6DGgiE3J8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCugABHuqqPZQjV9cEm0JFh7R5aiA",
|
||||
|
@ -1273,7 +1326,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1287,7 +1340,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "EEMcIZAcKjc",
|
||||
name: "LIVE EEVblog Mailbag",
|
||||
duration: Some(7344),
|
||||
length: Some(7344),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/EEMcIZAcKjc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCg16HpJqC9mNwkYOf8b0cfAuNLOA",
|
||||
|
@ -1314,7 +1367,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(884000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
|
|
@ -5,9 +5,7 @@ expression: map_res.c
|
|||
Channel(
|
||||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
handle: None,
|
||||
subscriber_count: Some(881000),
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
|
||||
|
@ -25,7 +23,7 @@ Channel(
|
|||
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",
|
||||
tags: [
|
||||
"electronics",
|
||||
|
@ -57,6 +55,7 @@ Channel(
|
|||
"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",
|
||||
|
@ -89,6 +88,60 @@ Channel(
|
|||
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("CgttaWpyTVpUN1AyZyioqr2ZBg%3D%3D"),
|
||||
|
@ -109,7 +162,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(2),
|
||||
|
@ -128,7 +181,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(1),
|
||||
|
@ -147,7 +200,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(9),
|
||||
|
@ -166,7 +219,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(2),
|
||||
|
@ -185,7 +238,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(4),
|
||||
|
@ -204,7 +257,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(18),
|
||||
|
@ -223,7 +276,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(3),
|
||||
|
@ -242,7 +295,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(8),
|
||||
|
@ -261,7 +314,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(13),
|
||||
|
@ -280,7 +333,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(9),
|
||||
|
@ -299,7 +352,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(7),
|
||||
|
@ -318,7 +371,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(3),
|
||||
|
@ -337,7 +390,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(8),
|
||||
|
@ -356,7 +409,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(2),
|
||||
|
@ -375,7 +428,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(3),
|
||||
|
@ -394,7 +447,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(10),
|
||||
|
@ -413,7 +466,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(1),
|
||||
|
@ -432,7 +485,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(9),
|
||||
|
@ -451,7 +504,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(16),
|
||||
|
@ -470,7 +523,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(7),
|
||||
|
@ -489,7 +542,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(6),
|
||||
|
@ -508,7 +561,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(12),
|
||||
|
@ -527,7 +580,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(1),
|
||||
|
@ -546,7 +599,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(5),
|
||||
|
@ -565,7 +618,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(2),
|
||||
|
@ -584,7 +637,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(4),
|
||||
|
@ -603,7 +656,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(1),
|
||||
|
@ -622,7 +675,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(2),
|
||||
|
@ -641,7 +694,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
video_count: Some(9),
|
||||
|
@ -660,7 +713,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(881000),
|
||||
)),
|
||||
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(
|
||||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
handle: Some("@Doobydobap"),
|
||||
subscriber_count: Some(3360000),
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.googleusercontent.com/dm5Aq93xvVJz0NoVO88ieBkDXmuShCujGPlZ7qETMEPTrXvPUCFI3-BB6Xs_P-r6Uk3mnBy9zA=s48-c-k-c0x00ffffff-no-rj",
|
||||
|
@ -25,9 +23,10 @@ Channel(
|
|||
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",
|
||||
tags: [],
|
||||
vanity_url: Some("https://www.youtube.com/@Doobydobap"),
|
||||
banner: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.googleusercontent.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
|
@ -60,6 +59,60 @@ Channel(
|
|||
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_live: false,
|
||||
visitor_data: Some("CgtHU1dvWkR4cGRfdyjMpt6iBg%3D%3D"),
|
||||
|
@ -69,7 +122,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "bGXP83AU3Mc",
|
||||
name: "do u wanna get swole?",
|
||||
duration: None,
|
||||
length: Some(53),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/bGXP83AU3Mc/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC9bzCBeHDbZFLE84Up3IiBIsxmmA",
|
||||
|
@ -81,7 +134,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -95,7 +148,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "E52sSgZlgYs",
|
||||
name: "the holy trinity of korean street food",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/E52sSgZlgYs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDBahtFRcfBInHuA8CjXFPWkF2jHg",
|
||||
|
@ -107,7 +160,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -121,7 +174,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "ovaHmfy3O6U",
|
||||
name: "hangover food",
|
||||
duration: None,
|
||||
length: Some(58),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/ovaHmfy3O6U/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCHmvWlG06h-DT6oxfmh69JGQ69KA",
|
||||
|
@ -133,7 +186,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -147,7 +200,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "FHTQmKTZnlI",
|
||||
name: "pig trotter raguuuuuuuuu 💅",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/FHTQmKTZnlI/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD0xhka1osA4nI3VCwhQusn3ND3Hg",
|
||||
|
@ -159,7 +212,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -173,7 +226,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "1AXB0l_wKMs",
|
||||
name: "what i ate in google japan",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/1AXB0l_wKMs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBsfYJ0KffUNn-9jBzNRTqetyFr8g",
|
||||
|
@ -185,7 +238,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -199,7 +252,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "1ARLtk3HiB0",
|
||||
name: "succumb to your cravings",
|
||||
duration: None,
|
||||
length: Some(53),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/1ARLtk3HiB0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBY9E40Ehvq862CVItJy0Uj_pS5bg",
|
||||
|
@ -211,7 +264,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -225,7 +278,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "0FfDoDHpaN8",
|
||||
name: "you can\'t let the what ifs rule your life",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/0FfDoDHpaN8/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBiV2TVPO-VbIjoNtwCKmFuxmj6LA",
|
||||
|
@ -237,7 +290,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -251,7 +304,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "kuT90_RIdF0",
|
||||
name: "duck confit lollipop 🦆🍭",
|
||||
duration: None,
|
||||
length: Some(59),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kuT90_RIdF0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCUN-DW72m7sAXJMgVkWNxPYpJBcQ",
|
||||
|
@ -263,7 +316,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -277,7 +330,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "aPJLhrcM4Yg",
|
||||
name: "HOUSE TOUR",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/aPJLhrcM4Yg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD1TbWAIbzyWq8AXLoW0xqaji3ukQ",
|
||||
|
@ -289,7 +342,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -303,7 +356,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "DKQrG_hJJX4",
|
||||
name: "how to meal prep like a korean",
|
||||
duration: None,
|
||||
length: Some(59),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/DKQrG_hJJX4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBE2DnpLFvtXsZOu1Ta4JQeOToVAw",
|
||||
|
@ -315,7 +368,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -329,7 +382,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "lNizW_P_oVw",
|
||||
name: "Rating Everything I ate at McDonald\'s Japan 🇯🇵",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/lNizW_P_oVw/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBI5XrkQ9Hesbf4lWELy7Uk3yMGMg",
|
||||
|
@ -341,7 +394,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -355,7 +408,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "kbWyJjrCjwA",
|
||||
name: "enemies as fertilizer √(veg)",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kbWyJjrCjwA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDlk30Km1M0jze1M3O90fB2LdvoAQ",
|
||||
|
@ -367,7 +420,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -381,7 +434,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "xAp910JTDig",
|
||||
name: "let\'s make some cabbage rolls for lunch",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/xAp910JTDig/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAJtpPGRgffBu9WDXACbtiGa_oRgA",
|
||||
|
@ -393,7 +446,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -407,7 +460,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "vSL7dhKatEk",
|
||||
name: "Rating Everything I ate at IKEA Korea",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/vSL7dhKatEk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBYpIDYbwwWiCqRNVi6PlfEfjrt4A",
|
||||
|
@ -419,7 +472,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -433,7 +486,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "LZzhUpACXSk",
|
||||
name: "I\'m done being the bigger person",
|
||||
duration: None,
|
||||
length: Some(59),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/LZzhUpACXSk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAFTvhtVUP7QZ4P7U70-0XH7PzDDg",
|
||||
|
@ -445,7 +498,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -459,7 +512,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "5C7nqNDfhis",
|
||||
name: "we\'re cooking a whole bird today",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/5C7nqNDfhis/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLA9I9irDsRjikwd0aqp1FWNFtjAqA",
|
||||
|
@ -471,7 +524,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -485,7 +538,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "6mj4Af0kUOQ",
|
||||
name: "men will disappoint but never potatoes",
|
||||
duration: None,
|
||||
length: Some(50),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/6mj4Af0kUOQ/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAVxl-FPt878AQXPBhbV1VSGeR8sw",
|
||||
|
@ -497,7 +550,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -511,7 +564,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "1c3axhSJiaQ",
|
||||
name: "I used to hate korean food",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/1c3axhSJiaQ/hq720_2.jpg?sqp=-oaymwEdCJUDENAFSFXyq4qpAw8IARUAAIhCcAHAAQbQAQE=&rs=AOn4CLBucOEbTsWTDjOOCjNa-fAvz1yxyA",
|
||||
|
@ -523,7 +576,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -537,7 +590,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "F9Vz0m7DPeU",
|
||||
name: "Rating everything I got at 7/11 Hawaii ( ft. Mauna Kea )",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/F9Vz0m7DPeU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDOoCVL6la3ztUeQ6vP4iL1cEBRjQ",
|
||||
|
@ -549,7 +602,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -563,7 +616,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Uey7kl56wks",
|
||||
name: "Grabbing Snacks from 7/11 Hawaii",
|
||||
duration: None,
|
||||
length: Some(49),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Uey7kl56wks/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCWmgajinNtIEbiPbqEtDvkC7Ydrg",
|
||||
|
@ -575,7 +628,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -589,7 +642,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "3un2eUAr6Dg",
|
||||
name: "cheesy korean corn balls hit different",
|
||||
duration: None,
|
||||
length: Some(46),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/3un2eUAr6Dg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD4LziL6GHd1jg8btMJDIM_RhgE_A",
|
||||
|
@ -601,7 +654,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -615,7 +668,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "rI5tWrGpDJA",
|
||||
name: "hawaiian tajin?!?",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/rI5tWrGpDJA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAjNiKHdFSKGavBrZRDxi9WdR-gJw",
|
||||
|
@ -627,7 +680,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -641,7 +694,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "WQiGksTxr5g",
|
||||
name: "Rating everything I ate at Hawaiian Supermarket 🌺🏰 pt.2",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/WQiGksTxr5g/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCvzC5xVdTEJX8xtiOqzmeKvmouIg",
|
||||
|
@ -653,7 +706,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -667,7 +720,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "G7aw-QOsagk",
|
||||
name: "Grocery Shopping at Hawaiian Supermarket 🌺🏰 pt.1",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/G7aw-QOsagk/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAoKEkj2lqYU07yW_DU35TNHEOq4w",
|
||||
|
@ -679,7 +732,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -693,7 +746,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Y_F1_Yf-DKQ",
|
||||
name: "Breakfast at Hawaiian McDonald\'s 🌺",
|
||||
duration: None,
|
||||
length: Some(61),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
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",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -719,7 +772,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Q_ZMcP8faw4",
|
||||
name: "crab rangoon toast 🦀 🍞",
|
||||
duration: None,
|
||||
length: Some(55),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Q_ZMcP8faw4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLATLiHTNqLoBKsEKbOckkGjXMvoHA",
|
||||
|
@ -731,7 +784,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -745,7 +798,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "1aedyP3r3D0",
|
||||
name: "my secret hot pot sauce 🧙\u{200d}♀\u{fe0f}🍃",
|
||||
duration: None,
|
||||
length: Some(59),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/1aedyP3r3D0/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCh2MpR5k3jCS_wfX-wjtVuIcu7YQ",
|
||||
|
@ -757,7 +810,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -771,7 +824,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "fkPkHZ1yyBU",
|
||||
name: "the good vs the bad",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/fkPkHZ1yyBU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCMngiRtLrBPppmfPnJwJ-cYMwttA",
|
||||
|
@ -783,7 +836,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -797,7 +850,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "NbQcySLMLmA",
|
||||
name: "cooking with waste?!🗑\u{fe0f}",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/NbQcySLMLmA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCvxPQo9eqYwjk4cxyBnrHed-tcZg",
|
||||
|
@ -809,7 +862,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -823,7 +876,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "3w_5vzM1Pc4",
|
||||
name: "Shrek burger 🍔🍀👹",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/3w_5vzM1Pc4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLB64zOKgmhOt7bvQseeIbjKBICDAg",
|
||||
|
@ -835,7 +888,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -849,7 +902,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "girJP2r_zLg",
|
||||
name: "$$$ on food",
|
||||
duration: None,
|
||||
length: Some(55),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/girJP2r_zLg/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBg2hmruZvx30aiP4Jb4dhz03qOZA",
|
||||
|
@ -861,7 +914,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -875,7 +928,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "zHp7sZ5OONM",
|
||||
name: "pumpkin spice churro?! 🎃",
|
||||
duration: None,
|
||||
length: Some(58),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/zHp7sZ5OONM/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLD8ZrcI6mq91ARKnRb_vg-0Qv2raw",
|
||||
|
@ -887,7 +940,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -901,7 +954,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "iqMl3gQEZ0E",
|
||||
name: "3,000,000",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/iqMl3gQEZ0E/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBUC1sw84NlLiyTJTcfnDWFjVC75w",
|
||||
|
@ -913,7 +966,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -927,7 +980,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "glyJWxp7a5g",
|
||||
name: "being smart was my personality trait",
|
||||
duration: None,
|
||||
length: Some(56),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/glyJWxp7a5g/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBbrWwLndPt5ZV5x4dnqmTC_aAhig",
|
||||
|
@ -939,7 +992,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -953,7 +1006,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "dd1EZIkANYs",
|
||||
name: "the horror maze",
|
||||
duration: None,
|
||||
length: Some(44),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/dd1EZIkANYs/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBlqz2BM3K2VeLlXMPBVwXNXih6vg",
|
||||
|
@ -965,7 +1018,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -979,7 +1032,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "enioc_stRww",
|
||||
name: "furikake bagels with wasabi cream cheese",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/enioc_stRww/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBz9Qo96FWssNsMhQ54DMxdYYwLfQ",
|
||||
|
@ -991,7 +1044,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1005,7 +1058,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "NUM8kCPas5w",
|
||||
name: "simple is best",
|
||||
duration: None,
|
||||
length: Some(49),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/NUM8kCPas5w/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC8N3YRr9A6-u6L0AtMynct4C_GzQ",
|
||||
|
@ -1017,7 +1070,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1031,7 +1084,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "1djkcsFnlYE",
|
||||
name: "edible history lesson!",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/1djkcsFnlYE/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBHn_6yOrnRXH_zbxVaAuKzSulcew",
|
||||
|
@ -1043,7 +1096,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1057,7 +1110,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "cIYrJtAoftI",
|
||||
name: "and I\'m feeling good",
|
||||
duration: None,
|
||||
length: Some(53),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/cIYrJtAoftI/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLC4q0VcbBZroejhAztDkdlk7Ww5Og",
|
||||
|
@ -1069,7 +1122,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1083,7 +1136,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "cCrH8Er5tf4",
|
||||
name: "Rating Korean Convenience Store Milk Flavors 🥛🍼",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/cCrH8Er5tf4/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBwc2ikrGH_gZfcyqTnZDfHjt5LuA",
|
||||
|
@ -1095,7 +1148,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1109,7 +1162,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "tav5wsH7pzU",
|
||||
name: "online dating?",
|
||||
duration: None,
|
||||
length: Some(58),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/tav5wsH7pzU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCheup7XAM_O1UAMEO5Iqup4-lGRQ",
|
||||
|
@ -1121,7 +1174,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1135,7 +1188,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "5Vd4_GXjF7o",
|
||||
name: "Creating thumbnails has never been easier with Adobe Express",
|
||||
duration: None,
|
||||
length: Some(26),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/5Vd4_GXjF7o/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCbYkH7INYGHW0IcO3DKip5iD2PCA",
|
||||
|
@ -1147,7 +1200,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1161,7 +1214,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "-FN1sEI8HkU",
|
||||
name: "my favorite color is green",
|
||||
duration: None,
|
||||
length: Some(45),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/-FN1sEI8HkU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLCLWKPrR-VCdsXagJ1MIyah7dDdDQ",
|
||||
|
@ -1173,7 +1226,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1187,7 +1240,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "viT-dcl2DGE",
|
||||
name: "frodo baggins?",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/viT-dcl2DGE/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDb0oYC_3V79CSR0j-4sR4CuNQekQ",
|
||||
|
@ -1199,7 +1252,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1213,7 +1266,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "N5AKQflK1TU",
|
||||
name: "When you impulse buy...",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/N5AKQflK1TU/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLDwfPTcuQHyziYsmTrSkg9xi1jnag",
|
||||
|
@ -1225,7 +1278,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1239,7 +1292,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "OzIFALQ_YtA",
|
||||
name: "taste testing gam!",
|
||||
duration: None,
|
||||
length: Some(60),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/OzIFALQ_YtA/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBMcyG6Fu4rrXk-JQL5tx0hzSAxlw",
|
||||
|
@ -1251,7 +1304,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1265,7 +1318,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "dAcJILbc_0Q",
|
||||
name: "How to: Korean rice wine 🍶 (makgeolli)",
|
||||
duration: None,
|
||||
length: Some(59),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/dAcJILbc_0Q/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLAXbHym4PFTTO25GCI4n1tjSaQVCw",
|
||||
|
@ -1277,7 +1330,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1291,7 +1344,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "GvutfmW26JQ",
|
||||
name: "👹stay sour 🍋",
|
||||
duration: None,
|
||||
length: Some(52),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/GvutfmW26JQ/oar2.jpg?sqp=-oaymwEaCJUDENAFSFXyq4qpAwwIARUAAIhCcAHAAQY=&rs=AOn4CLBgCJ06W3wOend0UgkuBKoHOg0eig",
|
||||
|
@ -1303,7 +1356,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(3360000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
|
|
@ -5,9 +5,7 @@ expression: map_res.c
|
|||
Channel(
|
||||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
handle: None,
|
||||
subscriber_count: Some(2930000),
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/dm5Aq93xvVJz0NoVO88ieBkDXmuShCujGPlZ7qETMEPTrXvPUCFI3-BB6Xs_P-r6Uk3mnBy9zA=s48-c-k-c0x00ffffff-no-rj",
|
||||
|
@ -25,9 +23,10 @@ Channel(
|
|||
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",
|
||||
tags: [],
|
||||
vanity_url: Some("https://www.youtube.com/c/Doobydobap"),
|
||||
banner: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/BvnAqgiursrXpmS9AgDLtkOSTQfOG_Dqn0KzY5hcwO9XrHTEQTVgaflI913f9KRp7d0U2qBp=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
|
@ -60,6 +59,60 @@ Channel(
|
|||
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_live: false,
|
||||
visitor_data: Some("CgtQdE9zVVR3NVBDbyjz0ZKaBg%3D%3D"),
|
||||
|
@ -69,7 +122,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "EIcmfSzeaKk",
|
||||
name: "our new normal",
|
||||
duration: Some(1106),
|
||||
length: Some(1106),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/EIcmfSzeaKk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAsYqYyFrXWHOkwiw0oqls2tGrKQg",
|
||||
|
@ -96,7 +149,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -110,7 +163,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "9NuhKCv3crg",
|
||||
name: "the end.",
|
||||
duration: Some(982),
|
||||
length: Some(982),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/9NuhKCv3crg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDB0KHjIok8E-gjwidP56UeDJy7Bg",
|
||||
|
@ -137,7 +190,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -151,7 +204,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "38Gd6TdmNVs",
|
||||
name: "KOREAN BARBECUE l doob gourmand ep.3",
|
||||
duration: Some(525),
|
||||
length: Some(525),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/38Gd6TdmNVs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBRo5niO28TGS9JNluTU9wCLCGBQA",
|
||||
|
@ -178,7 +231,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -192,7 +245,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "l9TiwunjzgA",
|
||||
name: "long distance",
|
||||
duration: Some(1043),
|
||||
length: Some(1043),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/l9TiwunjzgA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDjM6SZ7ScyfFRr13QdVmIvWEWWrQ",
|
||||
|
@ -219,7 +272,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -233,7 +286,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "pRVSdUxdsVw",
|
||||
name: "Repairing...",
|
||||
duration: Some(965),
|
||||
length: Some(965),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/pRVSdUxdsVw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAQWneuYcJcccgooBfa3WI4LdYF3w",
|
||||
|
@ -260,7 +313,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -274,7 +327,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "2FJVhdOO0F0",
|
||||
name: "a health scare",
|
||||
duration: Some(1238),
|
||||
length: Some(1238),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/2FJVhdOO0F0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA5ambaz-euRsB9VG5ANaYFUUSEbg",
|
||||
|
@ -301,7 +354,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -315,7 +368,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "CutR_1SDDzY",
|
||||
name: "feels good to be back",
|
||||
duration: Some(1159),
|
||||
length: Some(1159),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/CutR_1SDDzY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAt413Uk4xhHjYwpLI5-DXuOsFouA",
|
||||
|
@ -342,7 +395,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -356,7 +409,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "KUz7oArksR4",
|
||||
name: "running away",
|
||||
duration: Some(1023),
|
||||
length: Some(1023),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/KUz7oArksR4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD1NwuIgJuJy2oPAiHqMre6rbcuPA",
|
||||
|
@ -383,7 +436,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -397,7 +450,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "sPb2gyN-hnE",
|
||||
name: "worth fighting for",
|
||||
duration: Some(1232),
|
||||
length: Some(1232),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/sPb2gyN-hnE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBidXnS47SJMkvOlqt2DgzHxr6wKQ",
|
||||
|
@ -424,7 +477,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -438,7 +491,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "PXsK9-CFoH4",
|
||||
name: "waiting...",
|
||||
duration: Some(1455),
|
||||
length: Some(1455),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/PXsK9-CFoH4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBJ-57qZ-dOIsdFy5H8WT9UsS2W9w",
|
||||
|
@ -465,7 +518,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -479,7 +532,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "r2ye6zW0nbM",
|
||||
name: "a wedding",
|
||||
duration: Some(1207),
|
||||
length: Some(1207),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/r2ye6zW0nbM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB3L2DVtMtxaPaFjVPcNnjDHE5Wvw",
|
||||
|
@ -506,7 +559,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -520,7 +573,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "rriwHj8U664",
|
||||
name: "my seoul apartment tour",
|
||||
duration: Some(721),
|
||||
length: Some(721),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/rriwHj8U664/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy6zauLaf2KLJ6R41q0CPM8298PA",
|
||||
|
@ -547,7 +600,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -561,7 +614,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "FKJtrUeol3o",
|
||||
name: "with quantity comes quality",
|
||||
duration: Some(1140),
|
||||
length: Some(1140),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/FKJtrUeol3o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD7U0hZPrEiHZcTVcicymOllR05qw",
|
||||
|
@ -588,7 +641,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -602,7 +655,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "zYHB38UlzE0",
|
||||
name: "Q&A l relationships, burnout, privilege, college advice, living alone, and life after youtube?",
|
||||
duration: Some(775),
|
||||
length: Some(775),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/zYHB38UlzE0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDAO5etokCiF7cvyR-7kobN9RhTLA",
|
||||
|
@ -629,7 +682,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -643,7 +696,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "hGbQ2WM9nOo",
|
||||
name: "Why does everything bad for you taste good ㅣ CHILI OIL RAMEN",
|
||||
duration: Some(428),
|
||||
length: Some(428),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/hGbQ2WM9nOo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD_bMKoJhW-ifemEiqSBj-6dvEnUg",
|
||||
|
@ -670,7 +723,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -684,7 +737,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "PxGmP4v_A38",
|
||||
name: "Alone and Thriving l late night korean convenience store, muji kitchenware haul, spring cleaning!",
|
||||
duration: Some(1437),
|
||||
length: Some(1437),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/PxGmP4v_A38/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLArZRyFU5e71-vMdGZzuxCCroEkww",
|
||||
|
@ -711,7 +764,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -725,7 +778,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "8t-WyYcpEDE",
|
||||
name: "What I hate most",
|
||||
duration: Some(61),
|
||||
length: Some(61),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/8t-WyYcpEDE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsJHHXMP4fUEFqn-LExXU5yPyZ-Q",
|
||||
|
@ -752,7 +805,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -766,7 +819,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "RroYpLxxNjY",
|
||||
name: "I\'m Back. ㅣ cooking korean food, eating alone, working out, and 2M!",
|
||||
duration: Some(1313),
|
||||
length: Some(1313),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/RroYpLxxNjY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYJ_dcqUt2kR-4jOAUu8O0Ja9SLA",
|
||||
|
@ -793,7 +846,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -807,7 +860,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "l47QuudsZ34",
|
||||
name: "We ate our way through Florence (ft. mamadooby)",
|
||||
duration: Some(1109),
|
||||
length: Some(1109),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/l47QuudsZ34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB0Vwc7DhN_hFXSRuDAiivLnGGc2A",
|
||||
|
@ -834,7 +887,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -848,7 +901,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "1VW7iXRIrc8",
|
||||
name: "Alone, in the City of Love",
|
||||
duration: Some(1875),
|
||||
length: Some(1875),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/1VW7iXRIrc8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBbNxPLmGzJlvJ-3o5Dz9I5LOGu1A",
|
||||
|
@ -875,7 +928,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -889,7 +942,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "6c58-749p6Y",
|
||||
name: "Old Friends & New",
|
||||
duration: Some(774),
|
||||
length: Some(774),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/6c58-749p6Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClRrTlOF_Q3feHLoM0T5_DFygbIw",
|
||||
|
@ -916,7 +969,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -930,7 +983,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Q2G53LuEUaU",
|
||||
name: "Where we stand",
|
||||
duration: Some(858),
|
||||
length: Some(858),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Q2G53LuEUaU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC1ppNrqK-xlQ6Sxnn62dp8QXoJBQ",
|
||||
|
@ -957,7 +1010,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -971,7 +1024,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "8rAOeowNQrI",
|
||||
name: "That\'s so last year",
|
||||
duration: Some(1286),
|
||||
length: Some(1286),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/8rAOeowNQrI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCSBW_fD0pttfFh4Yc_Kx1UIZHzfg",
|
||||
|
@ -998,7 +1051,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1012,7 +1065,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "0RGIdIKkbSI",
|
||||
name: "The Muffin Man",
|
||||
duration: Some(1052),
|
||||
length: Some(1052),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/0RGIdIKkbSI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDo42DBFMfLKVHtXETG5yuU20FVMw",
|
||||
|
@ -1039,7 +1092,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1053,7 +1106,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "NudTbo2CJMY",
|
||||
name: "Flying to London",
|
||||
duration: Some(1078),
|
||||
length: Some(1078),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/NudTbo2CJMY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEdvWWmhSaDTTx7b2kJUauMFnQJQ",
|
||||
|
@ -1080,7 +1133,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1094,7 +1147,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "8mJk1ncGZig",
|
||||
name: "(not so) Teenage Angst",
|
||||
duration: Some(1376),
|
||||
length: Some(1376),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/8mJk1ncGZig/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB1kTcJ6oRyNfaGJbvl6V5UxRhagg",
|
||||
|
@ -1121,7 +1174,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1135,7 +1188,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "qvgCi2WpbfE",
|
||||
name: "can\'t smell :s",
|
||||
duration: Some(875),
|
||||
length: Some(875),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/qvgCi2WpbfE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBdSLKkLlOTxprZAH9BajRpHiujrw",
|
||||
|
@ -1162,7 +1215,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1176,7 +1229,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Sm4Yqtqr9f8",
|
||||
name: "I have covid",
|
||||
duration: Some(814),
|
||||
length: Some(814),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Sm4Yqtqr9f8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDAAWaXioP-Xz_cwkE3APR_5fpkqw",
|
||||
|
@ -1203,7 +1256,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1217,7 +1270,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "ZRtf4ksF3qs",
|
||||
name: "Everything I ate in Busan & make up tutorial??",
|
||||
duration: Some(1026),
|
||||
length: Some(1026),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/ZRtf4ksF3qs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBnRStN9mU3cu7vDQIkUcO3WiyVZw",
|
||||
|
@ -1244,7 +1297,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1258,7 +1311,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "oG4Wth1oVBQ",
|
||||
name: "On the other side",
|
||||
duration: Some(1592),
|
||||
length: Some(1592),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oG4Wth1oVBQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDO45Wm2zkuD6ZukxaoxfgGkpuZHg",
|
||||
|
@ -1285,7 +1338,7 @@ Channel(
|
|||
id: "UCh8gHdtzO2tXd593_bjErWg",
|
||||
name: "Doobydobap",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2930000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
|
|
@ -5,9 +5,7 @@ expression: map_res.c
|
|||
Channel(
|
||||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
handle: None,
|
||||
subscriber_count: Some(883000),
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
|
||||
|
@ -25,7 +23,7 @@ Channel(
|
|||
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",
|
||||
tags: [
|
||||
"electronics",
|
||||
|
@ -57,6 +55,7 @@ Channel(
|
|||
"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",
|
||||
|
@ -89,6 +88,60 @@ Channel(
|
|||
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: true,
|
||||
visitor_data: Some("Cgs4ZFVmMzVlU1dxbyiBqpeaBg%3D%3D"),
|
||||
|
@ -98,7 +151,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "4EcQYK_no5M",
|
||||
name: "EEVblog 1506 - History of Electricity with Kathy Loves Physics",
|
||||
duration: Some(6143),
|
||||
length: Some(6143),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/4EcQYK_no5M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB9dr9RxHmrRUim7aDSz_mPNrfSKA",
|
||||
|
@ -125,7 +178,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -139,7 +192,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "zEzjVUzNAFA",
|
||||
name: "EEVblog 1505 - 120W Home Phantom Power? Audit Time!",
|
||||
duration: Some(1464),
|
||||
length: Some(1464),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/zEzjVUzNAFA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnSdLCdtqGA1HYCFv4_MeTHWdVpw",
|
||||
|
@ -166,7 +219,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -180,7 +233,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "YIbQ3nudCA0",
|
||||
name: "EEVblog 1504 - The COOL thing you MISSED at Tesla AI Day 2022",
|
||||
duration: Some(1021),
|
||||
length: Some(1021),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/YIbQ3nudCA0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDesF0deVLo0ouizZ8ZF_lXolOdrw",
|
||||
|
@ -207,7 +260,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -221,7 +274,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "W1Jl0rMRGSg",
|
||||
name: "EEVblog 1503 - Rigol HDO4000 12bit Oscilloscope TEARDOWN",
|
||||
duration: Some(1798),
|
||||
length: Some(1798),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/W1Jl0rMRGSg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBKFi3YtWo1ii8h8FdQN6CkYgzX2A",
|
||||
|
@ -248,7 +301,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -262,7 +315,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "YFKu_emNzpk",
|
||||
name: "EEVblog 1502 - Is Home Battery Storage Financially Viable?",
|
||||
duration: Some(1199),
|
||||
length: Some(1199),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/YFKu_emNzpk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLACI3L7nXsK3ZUFD8yK0VAWd32-Uw",
|
||||
|
@ -289,7 +342,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -303,7 +356,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "gremHHvqYTE",
|
||||
name: "EEVblog 1501 - Rigol HDO4000 Low Noise 12bit Oscilloscope Unboxing & First Impression",
|
||||
duration: Some(1794),
|
||||
length: Some(1794),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/gremHHvqYTE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBcwR0YIwLjfFam9HkKdkTkqx_gHw",
|
||||
|
@ -330,7 +383,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -344,7 +397,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "WHO8NBfpaO0",
|
||||
name: "eevBLAB 102 - Last Mile Autonomous Robot Deliveries WILL FAIL",
|
||||
duration: Some(742),
|
||||
length: Some(742),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/WHO8NBfpaO0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQPKMF3Aeo9CydEWz9pQWkn1Lu7Q",
|
||||
|
@ -371,7 +424,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -385,7 +438,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "W1Q8CxL95_Y",
|
||||
name: "EEVblog 1500 - Automatic Transfer Switch REVERSE ENGINEERED",
|
||||
duration: Some(1770),
|
||||
length: Some(1770),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/W1Q8CxL95_Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBIxuct8vahJHOJTLfbOnsMOXnjvw",
|
||||
|
@ -412,7 +465,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -426,7 +479,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "lagxSrPeoYg",
|
||||
name: "EEVblog 1499 - EcoFlow Delta Pro 3.6kWh Portable Battery TEARDOWN!",
|
||||
duration: Some(2334),
|
||||
length: Some(2334),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/lagxSrPeoYg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAbAX2gdAF66O7BUCaOVg2vQOsS2Q",
|
||||
|
@ -453,7 +506,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -467,7 +520,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "qTctWW9_FmE",
|
||||
name: "EEVblog 1498 - TransPod Fluxjet Hyperloop $550M Boondoggle!",
|
||||
duration: Some(2399),
|
||||
length: Some(2399),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/qTctWW9_FmE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCbnEQaGGI5zD9lCJ8kMmciezX2kA",
|
||||
|
@ -494,7 +547,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -508,7 +561,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "3t9G80wk0pk",
|
||||
name: "eevBLAB 101 - Why Are Tektronix Oscilloscopes So Expensive?",
|
||||
duration: Some(1423),
|
||||
length: Some(1423),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/3t9G80wk0pk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnsVu-VQplpRpc1ZW-yk2byyZjZA",
|
||||
|
@ -535,7 +588,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -549,7 +602,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "7dze5CnZnmk",
|
||||
name: "EEVblog 1497 - RIP Fluke. Thanks Energizer. NOT.",
|
||||
duration: Some(1168),
|
||||
length: Some(1168),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/7dze5CnZnmk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg430MYAmoycM4lbv_57S_d3kZRA",
|
||||
|
@ -576,7 +629,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -590,7 +643,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "6XnrZpPYgBg",
|
||||
name: "EEVblog 1496 - Winning Mailbag",
|
||||
duration: Some(3139),
|
||||
length: Some(3139),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/6XnrZpPYgBg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCrBgky13jB1p9xzKbmoUpJ4g0SNQ",
|
||||
|
@ -617,7 +670,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -631,7 +684,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Psp3ltpFvws",
|
||||
name: "eevBLAB 100 - Reuters Attacks Odysee - LOL",
|
||||
duration: Some(855),
|
||||
length: Some(855),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Psp3ltpFvws/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCu8Nu_NmDw5vBHgb7e8JdJR1Dr1Q",
|
||||
|
@ -658,7 +711,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -672,7 +725,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "taVYTYz5vLE",
|
||||
name: "EEVblog 1495 - Quaze Wireless Power (AGAIN!) but for GAMING!",
|
||||
duration: Some(2592),
|
||||
length: Some(2592),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/taVYTYz5vLE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAMHngmN8TjWZz327vUD7zjjblYBw",
|
||||
|
@ -699,7 +752,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -713,7 +766,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Y6cZrieFw-k",
|
||||
name: "EEVblog 1494 - FIVE Ways to Open a CHEAP SAFE!",
|
||||
duration: Some(1194),
|
||||
length: Some(1194),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Y6cZrieFw-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsdoJwcvSFZU4e9cwDFbZj3W21Pw",
|
||||
|
@ -740,7 +793,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -754,7 +807,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Kr2XyhpUdUI",
|
||||
name: "EEVblog 1493 - MacGyver Project - Part 2",
|
||||
duration: Some(1785),
|
||||
length: Some(1785),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Kr2XyhpUdUI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdL3brjOzbABRuyz-yolawtGRsbw",
|
||||
|
@ -781,7 +834,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -795,7 +848,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "rxGafdgkal8",
|
||||
name: "EEVblog 1492 - $5 Oscilloscope Repaired! + Oz GIVEAWAY",
|
||||
duration: Some(1163),
|
||||
length: Some(1163),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/rxGafdgkal8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD-p_t0q_Q2oTGyJuFCQJ5z6VPPMQ",
|
||||
|
@ -822,7 +875,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -836,7 +889,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "4yosozyeIP4",
|
||||
name: "EEVblog 1491 - The MacGyver Project - Part 1",
|
||||
duration: Some(1706),
|
||||
length: Some(1706),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/4yosozyeIP4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDRNAWkPQfuQirfiOdowD1iQlWrWg",
|
||||
|
@ -863,7 +916,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -877,7 +930,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "06JtC2DC_dQ",
|
||||
name: "EEVblog 1490 - Insane Jaycar Dumpster Sale! 2022",
|
||||
duration: Some(1700),
|
||||
length: Some(1700),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/06JtC2DC_dQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDVIvEssIKji_8dyBYGYbpIqen7vQ",
|
||||
|
@ -904,7 +957,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -918,7 +971,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "piquT76w9TI",
|
||||
name: "EEVblog 1489 - Mystery Teardown!",
|
||||
duration: Some(1466),
|
||||
length: Some(1466),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/piquT76w9TI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCTzIcGeRDwUyINtik50EQCOTxwiA",
|
||||
|
@ -945,7 +998,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -959,7 +1012,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "pKuUKT-zU-g",
|
||||
name: "EEVblog 1488 - Tilt Five Augmented Reality AR Glasses - First Reaction!",
|
||||
duration: Some(2152),
|
||||
length: Some(2152),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/pKuUKT-zU-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCB6Rh4miI20yPy2kJaxul_wA3Now",
|
||||
|
@ -986,7 +1039,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1000,7 +1053,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "_R4wQQNSO6k",
|
||||
name: "EEVblog 1487 - Do Solar Micro Inverters Take Power at Night?",
|
||||
duration: Some(2399),
|
||||
length: Some(2399),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/_R4wQQNSO6k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEQVZ0yQPLZqwLdQednKWwLWqDmA",
|
||||
|
@ -1027,7 +1080,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1041,7 +1094,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "ikp5BorIo_M",
|
||||
name: "EEVblog 1486 - What you DIDN\'T KNOW About Film Capacitor FAILURES!",
|
||||
duration: Some(1792),
|
||||
length: Some(1792),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/ikp5BorIo_M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBygGB8KC522NC15BhDC1WpuNKsgw",
|
||||
|
@ -1068,7 +1121,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1082,7 +1135,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "7O-QckjCXNo",
|
||||
name: "eevBLAB 99 - AI SPAM BOT Youtube Space/Science/Tech Channels? - WTF",
|
||||
duration: Some(592),
|
||||
length: Some(592),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/7O-QckjCXNo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBY1cRnrWQCbmlAzP5okMmIYjgdsg",
|
||||
|
@ -1109,7 +1162,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1123,7 +1176,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "VutdTxF4E-0",
|
||||
name: "RIP The Old Garage Lab",
|
||||
duration: Some(115),
|
||||
length: Some(115),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/VutdTxF4E-0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDlPpT2-UOGfm2A2djTLjCsygeqSw",
|
||||
|
@ -1150,7 +1203,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1164,7 +1217,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "o7xfGuRaq94",
|
||||
name: "EEVblog 1485 - PedalCell CadenceX Bike Generator LOL FAIL!",
|
||||
duration: Some(1026),
|
||||
length: Some(1026),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/o7xfGuRaq94/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBA7RRL2USBwkYXp9ouWTbtU-JHSg",
|
||||
|
@ -1191,7 +1244,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1205,7 +1258,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "3WSIfHOv3fc",
|
||||
name: "EEVblog 1484 - Kaba Mas X-09 High Security Electronic Lock Teardown",
|
||||
duration: Some(1106),
|
||||
length: Some(1106),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/3WSIfHOv3fc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClZroFRo115ZuxYhJ5rcCDO2ZPcQ",
|
||||
|
@ -1232,7 +1285,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1246,7 +1299,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "8yXZJZCKImI",
|
||||
name: "EEVblog 1483 - Holy Mailbag Bomb Batman!",
|
||||
duration: Some(3373),
|
||||
length: Some(3373),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/8yXZJZCKImI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBym7WfmrvKIjs2ClW-FOLtxbENzw",
|
||||
|
@ -1273,7 +1326,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1287,7 +1340,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "vJ4pW6LKJWU",
|
||||
name: "EEVblog 1482 - Mains Capacitor Zener Regulator Circuit",
|
||||
duration: Some(1132),
|
||||
length: Some(1132),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/vJ4pW6LKJWU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDaKgfAJ4NAeqoMIPZDavsTw_JD5w",
|
||||
|
@ -1314,7 +1367,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(883000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
|
|
@ -5,9 +5,7 @@ expression: map_res.c
|
|||
Channel(
|
||||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
handle: Some("@Coachella"),
|
||||
subscriber_count: Some(2710000),
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "//yt3.googleusercontent.com/RDZ6VWFjHEMFm_QcmCCf-yG_UiGo9YWXEmVRuiHSC8SvP02dgeBEtAjd4CnEKGLo0V2gGdIRDQ=s88-c-k-c0x00ffffff-no-rj-mo",
|
||||
|
@ -20,7 +18,7 @@ Channel(
|
|||
height: 176,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
description: "April 14-16 & 21-23, 2023\n",
|
||||
tags: [
|
||||
"coachella",
|
||||
|
@ -33,7 +31,10 @@ Channel(
|
|||
"indio",
|
||||
"california",
|
||||
],
|
||||
vanity_url: Some("https://www.youtube.com/@Coachella"),
|
||||
banner: [],
|
||||
mobile_banner: [],
|
||||
tv_banner: [],
|
||||
has_shorts: true,
|
||||
has_live: true,
|
||||
visitor_data: Some("CgtjSUhDeVJ6SU5wNCj75uyhBg%3D%3D"),
|
||||
|
@ -43,7 +44,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "vFc_pAywtKc",
|
||||
name: "The Murder Capital - Return My Head - Live at Coachella 2023",
|
||||
duration: Some(194),
|
||||
length: Some(194),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/vFc_pAywtKc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAPDC5UHtj76ursSNJqBD-jAiSxHg",
|
||||
|
@ -70,7 +71,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -84,7 +85,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "3JprxZgfcHU",
|
||||
name: "BENEE - Supaloney - ft. Gus Dapperton - Live at Coachella 2023",
|
||||
duration: Some(270),
|
||||
length: Some(270),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/3JprxZgfcHU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCs4cytf-M3ksr1YZB0Iu22b3_Baw",
|
||||
|
@ -111,7 +112,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -125,7 +126,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "a4QufICobaA",
|
||||
name: "Doechii - What It Is - Live at Coachella 2023",
|
||||
duration: Some(185),
|
||||
length: Some(185),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/a4QufICobaA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC1bg4wXk4z0Tcp-PgPodKlRsf8lA",
|
||||
|
@ -152,7 +153,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -166,7 +167,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "QoRm-xhVqYU",
|
||||
name: "Gabriels - Blame - Live at Coachella 2023",
|
||||
duration: Some(170),
|
||||
length: Some(170),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/QoRm-xhVqYU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD9H8nfnmu-G2jIfTelbBNbiAWvqw",
|
||||
|
@ -193,7 +194,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -207,7 +208,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "28DbQYSsn1w",
|
||||
name: "Kaytranada - Intimidate - ft H.E.R. - Live at Coachella 2023",
|
||||
duration: Some(252),
|
||||
length: Some(252),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/28DbQYSsn1w/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAF_nO2I3hjct93i3p6V3H1Rmadcg",
|
||||
|
@ -234,7 +235,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -248,7 +249,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "nLFZFp3go3o",
|
||||
name: "SG Lewis - Impact - ft. Channel Tres - Live at Coachella 2023",
|
||||
duration: Some(365),
|
||||
length: Some(365),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/nLFZFp3go3o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBgQvHlztxcmQ3pkFNMKQpgvgMusA",
|
||||
|
@ -275,7 +276,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -289,7 +290,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "RWJMmYcPTR4",
|
||||
name: "MUNA - Silk Chiffon - Live at Coachella 2023",
|
||||
duration: Some(220),
|
||||
length: Some(220),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/RWJMmYcPTR4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFk7y7WiMC9pZ9zE1YSlh0TA5o5Q",
|
||||
|
@ -316,7 +317,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -330,7 +331,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "gcrW53SoTKs",
|
||||
name: "Pusha T - Diet Coke - Live at Coachella 2023",
|
||||
duration: Some(175),
|
||||
length: Some(175),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/gcrW53SoTKs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAlGMLr4aBbSqb-8HBAPeXGLtkOGg",
|
||||
|
@ -357,7 +358,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -371,7 +372,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "7pYqbVztRtk",
|
||||
name: "Blink 182 - I Miss You - Live at Coachella 2023",
|
||||
duration: Some(267),
|
||||
length: Some(267),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/7pYqbVztRtk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD4rIf8atgIc2nEptj4CjgOPqXVWw",
|
||||
|
@ -398,7 +399,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -412,7 +413,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "yzmSlPiaeRU",
|
||||
name: "Blink 182 - Whats My Age Again - Live at Coachella 2023",
|
||||
duration: Some(157),
|
||||
length: Some(157),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/yzmSlPiaeRU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzhu2omZ6arr3cGIEM1IGoIp_i3w",
|
||||
|
@ -439,7 +440,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -453,7 +454,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "r3Kpm4lEXmg",
|
||||
name: "Discover the Mirage, Part 2 - Coachella 2023",
|
||||
duration: Some(96),
|
||||
length: Some(96),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/r3Kpm4lEXmg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBLfc6awfa8Mv7I1nTLxfJRY4XUKQ",
|
||||
|
@ -480,7 +481,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -494,7 +495,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "LqrLCWoXR_k",
|
||||
name: "Coachella on YouTube 2023",
|
||||
duration: Some(31),
|
||||
length: Some(31),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/LqrLCWoXR_k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCgX8ylcJLaYZiR3Nvr5WrS_6mw8g",
|
||||
|
@ -521,7 +522,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -535,7 +536,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "J1cmYPtABo0",
|
||||
name: "Discover the Mirage, Part 1 - Coachella 2023",
|
||||
duration: Some(91),
|
||||
length: Some(91),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/J1cmYPtABo0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAEDuvdZhNVkmvG-usGm9tmgJt7QQ",
|
||||
|
@ -562,7 +563,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -576,7 +577,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "a0BuUhI3f20",
|
||||
name: "Coachella 2023 featuring Bad Bunny, BLACKPINK, Frank Ocean and more 🌵",
|
||||
duration: Some(31),
|
||||
length: Some(31),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/a0BuUhI3f20/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUCg-MA8=&rs=AOn4CLBDwxWN_SrIR8rCSQVokx1wfe1iqQ",
|
||||
|
@ -603,7 +604,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -617,7 +618,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "G2p-YqRGh80",
|
||||
name: "MEUTE Interview – Coachella Curated 2022",
|
||||
duration: Some(224),
|
||||
length: Some(224),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/G2p-YqRGh80/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgSSg-MA8=&rs=AOn4CLBWAFod2tomSeOXcy3y5EOIjimn9g",
|
||||
|
@ -644,7 +645,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -658,7 +659,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "eLZq4l37G7k",
|
||||
name: "Belly - Interview - Coachella 2022",
|
||||
duration: Some(302),
|
||||
length: Some(302),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/eLZq4l37G7k/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgPyg6MA8=&rs=AOn4CLBtjJQRABeVsxDVsYK2RwoTETjE8A",
|
||||
|
@ -685,7 +686,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -699,7 +700,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "ViPAf8JpMXY",
|
||||
name: "Still Woozy - Interview - Coachella 2022",
|
||||
duration: Some(304),
|
||||
length: Some(304),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/ViPAf8JpMXY/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUyhEMA8=&rs=AOn4CLBFMadm51TmtXHYl-3B3s1DS1NLoQ",
|
||||
|
@ -726,7 +727,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -740,7 +741,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "4PKCIRUOZRE",
|
||||
name: "Slander - Interview - Coachella 2022",
|
||||
duration: Some(259),
|
||||
length: Some(259),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/4PKCIRUOZRE/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgUChCMA8=&rs=AOn4CLD6iAmhCyMAwfcKJl18WeC_BrjyFQ",
|
||||
|
@ -767,7 +768,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -781,7 +782,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "0O7abvoOxro",
|
||||
name: "Run The Jewels - Interview Coachella",
|
||||
duration: Some(408),
|
||||
length: Some(408),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/0O7abvoOxro/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgXihNMA8=&rs=AOn4CLCYxlSf_-9OXuvGCVfY8caFGVaGeQ",
|
||||
|
@ -808,7 +809,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -822,7 +823,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "z1Q7ahNLU9o",
|
||||
name: "Rina Sawayama - Interview - Coachella 2022",
|
||||
duration: Some(297),
|
||||
length: Some(297),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/z1Q7ahNLU9o/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWCg_MA8=&rs=AOn4CLAFBsN92p3Xd5jd75JOkVQmFpRaOQ",
|
||||
|
@ -849,7 +850,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -863,7 +864,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "VB71WJvcdsM",
|
||||
name: "Rich Brian - Interview - Coachella 2022",
|
||||
duration: Some(371),
|
||||
length: Some(371),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/VB71WJvcdsM/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgVyhAMA8=&rs=AOn4CLAnNIA4THR0-WH60GnpECd_KRhUEQ",
|
||||
|
@ -890,7 +891,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -904,7 +905,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "FYr3OasngBI",
|
||||
name: "Masego - Interview - Coachella 2022",
|
||||
duration: Some(323),
|
||||
length: Some(323),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/FYr3OasngBI/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgXyg6MA8=&rs=AOn4CLAAT-2gUtrDLaKVDQmsUkKmkE__Lg",
|
||||
|
@ -931,7 +932,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -945,7 +946,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "BwDnV5sbFeU",
|
||||
name: "Louis The Child - Interview - Coachella 2022",
|
||||
duration: Some(360),
|
||||
length: Some(360),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/BwDnV5sbFeU/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWig2MA8=&rs=AOn4CLAXRG17JkByDUun5WIfMdVARqYwtg",
|
||||
|
@ -972,7 +973,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -986,7 +987,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "iH8KFwkMurQ",
|
||||
name: "Kim Petras - Interview - Coachella 2022",
|
||||
duration: Some(294),
|
||||
length: Some(294),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/iH8KFwkMurQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgVChIMA8=&rs=AOn4CLBzJcKvWEWZmdtorJ8P7tfMT1306A",
|
||||
|
@ -1013,7 +1014,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1027,7 +1028,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "NK96m-YTUaE",
|
||||
name: "Joe Kay - Interview - Coachella 2022",
|
||||
duration: Some(189),
|
||||
length: Some(189),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/NK96m-YTUaE/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWyhJMA8=&rs=AOn4CLD6ptJ2_2cwyY2pkGieoYscFjlVpQ",
|
||||
|
@ -1054,7 +1055,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1068,7 +1069,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "jnG1qLK0SiI",
|
||||
name: "Japanese Breakfast - Interview - Coachella 2022",
|
||||
duration: Some(312),
|
||||
length: Some(312),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/jnG1qLK0SiI/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgWCguMA8=&rs=AOn4CLBTEvxp-kJ7uYZwIaiylaohW_7wGQ",
|
||||
|
@ -1095,7 +1096,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1109,7 +1110,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "NdKnb1e9_qA",
|
||||
name: "Idles - Interview - Coachella 2022",
|
||||
duration: Some(395),
|
||||
length: Some(395),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/NdKnb1e9_qA/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGGUgWihTMA8=&rs=AOn4CLBEHQoRUkshAo-28mmB520wlFwlxA",
|
||||
|
@ -1136,7 +1137,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1150,7 +1151,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "o8LEidp-Dq8",
|
||||
name: "Freddie Gibbs - Interview - Coachella 2022",
|
||||
duration: Some(207),
|
||||
length: Some(207),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/o8LEidp-Dq8/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgNyhKMA8=&rs=AOn4CLBqrWHD5sKYIrl_Fj6dTSixhqFAbw",
|
||||
|
@ -1177,7 +1178,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1191,7 +1192,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "4-sEy0jxh-U",
|
||||
name: "Epik High - Interview - Coachella 2022",
|
||||
duration: Some(386),
|
||||
length: Some(386),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
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",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1232,7 +1233,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "YN5CjIFmx88",
|
||||
name: "Duke Dumont - Interview - Coachella 2022",
|
||||
duration: Some(443),
|
||||
length: Some(443),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/YN5CjIFmx88/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-CYAC0AWKAgwIABABGHIgUyhCMA8=&rs=AOn4CLAPYvywgTRHRSLHZaQXLC1-pdsIIg",
|
||||
|
@ -1259,7 +1260,7 @@ Channel(
|
|||
id: "UCHF66aWLOxBW4l6VkSrS3cQ",
|
||||
name: "Coachella",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(2710000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
|
|
@ -5,9 +5,7 @@ expression: map_res.c
|
|||
Channel(
|
||||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
handle: None,
|
||||
subscriber_count: Some(880000),
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
|
||||
|
@ -25,7 +23,7 @@ Channel(
|
|||
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",
|
||||
tags: [
|
||||
"electronics",
|
||||
|
@ -57,6 +55,7 @@ Channel(
|
|||
"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",
|
||||
|
@ -89,6 +88,60 @@ Channel(
|
|||
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("CgszNU5rbDVZS2hMcyim4K2ZBg%3D%3D"),
|
||||
|
@ -98,7 +151,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "gremHHvqYTE",
|
||||
name: "EEVblog 1501 - Rigol HDO4000 Low Noise 12bit Oscilloscope Unboxing & First Impression",
|
||||
duration: Some(1794),
|
||||
length: Some(1794),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/gremHHvqYTE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBcwR0YIwLjfFam9HkKdkTkqx_gHw",
|
||||
|
@ -125,7 +178,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -139,7 +192,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "WHO8NBfpaO0",
|
||||
name: "eevBLAB 102 - Last Mile Autonomous Robot Deliveries WILL FAIL",
|
||||
duration: Some(742),
|
||||
length: Some(742),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/WHO8NBfpaO0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQPKMF3Aeo9CydEWz9pQWkn1Lu7Q",
|
||||
|
@ -166,7 +219,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -180,7 +233,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "W1Q8CxL95_Y",
|
||||
name: "EEVblog 1500 - Automatic Transfer Switch REVERSE ENGINEERED",
|
||||
duration: Some(1770),
|
||||
length: Some(1770),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/W1Q8CxL95_Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBIxuct8vahJHOJTLfbOnsMOXnjvw",
|
||||
|
@ -207,7 +260,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -221,7 +274,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "lagxSrPeoYg",
|
||||
name: "EEVblog 1499 - EcoFlow Delta Pro 3.6kWh Portable Battery TEARDOWN!",
|
||||
duration: Some(2334),
|
||||
length: Some(2334),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/lagxSrPeoYg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAbAX2gdAF66O7BUCaOVg2vQOsS2Q",
|
||||
|
@ -248,7 +301,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -262,7 +315,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "qTctWW9_FmE",
|
||||
name: "EEVblog 1498 - TransPod Fluxjet Hyperloop $550M Boondoggle!",
|
||||
duration: Some(2399),
|
||||
length: Some(2399),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/qTctWW9_FmE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCbnEQaGGI5zD9lCJ8kMmciezX2kA",
|
||||
|
@ -289,7 +342,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -303,7 +356,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "3t9G80wk0pk",
|
||||
name: "eevBLAB 101 - Why Are Tektronix Oscilloscopes So Expensive?",
|
||||
duration: Some(1423),
|
||||
length: Some(1423),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/3t9G80wk0pk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDnsVu-VQplpRpc1ZW-yk2byyZjZA",
|
||||
|
@ -330,7 +383,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -344,7 +397,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "7dze5CnZnmk",
|
||||
name: "EEVblog 1497 - RIP Fluke. Thanks Energizer. NOT.",
|
||||
duration: Some(1168),
|
||||
length: Some(1168),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/7dze5CnZnmk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg430MYAmoycM4lbv_57S_d3kZRA",
|
||||
|
@ -371,7 +424,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -385,7 +438,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "6XnrZpPYgBg",
|
||||
name: "EEVblog 1496 - Winning Mailbag",
|
||||
duration: Some(3139),
|
||||
length: Some(3139),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/6XnrZpPYgBg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCrBgky13jB1p9xzKbmoUpJ4g0SNQ",
|
||||
|
@ -412,7 +465,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -426,7 +479,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Psp3ltpFvws",
|
||||
name: "eevBLAB 100 - Reuters Attacks Odysee - LOL",
|
||||
duration: Some(855),
|
||||
length: Some(855),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Psp3ltpFvws/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCu8Nu_NmDw5vBHgb7e8JdJR1Dr1Q",
|
||||
|
@ -453,7 +506,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -467,7 +520,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "taVYTYz5vLE",
|
||||
name: "EEVblog 1495 - Quaze Wireless Power (AGAIN!) but for GAMING!",
|
||||
duration: Some(2592),
|
||||
length: Some(2592),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/taVYTYz5vLE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAMHngmN8TjWZz327vUD7zjjblYBw",
|
||||
|
@ -494,7 +547,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -508,7 +561,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Y6cZrieFw-k",
|
||||
name: "EEVblog 1494 - FIVE Ways to Open a CHEAP SAFE!",
|
||||
duration: Some(1194),
|
||||
length: Some(1194),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Y6cZrieFw-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDsdoJwcvSFZU4e9cwDFbZj3W21Pw",
|
||||
|
@ -535,7 +588,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -549,7 +602,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "Kr2XyhpUdUI",
|
||||
name: "EEVblog 1493 - MacGyver Project - Part 2",
|
||||
duration: Some(1785),
|
||||
length: Some(1785),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/Kr2XyhpUdUI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdL3brjOzbABRuyz-yolawtGRsbw",
|
||||
|
@ -576,7 +629,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -590,7 +643,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "rxGafdgkal8",
|
||||
name: "EEVblog 1492 - $5 Oscilloscope Repaired! + Oz GIVEAWAY",
|
||||
duration: Some(1163),
|
||||
length: Some(1163),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/rxGafdgkal8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD-p_t0q_Q2oTGyJuFCQJ5z6VPPMQ",
|
||||
|
@ -617,7 +670,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -631,7 +684,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "4yosozyeIP4",
|
||||
name: "EEVblog 1491 - The MacGyver Project - Part 1",
|
||||
duration: Some(1706),
|
||||
length: Some(1706),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/4yosozyeIP4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDRNAWkPQfuQirfiOdowD1iQlWrWg",
|
||||
|
@ -658,7 +711,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -672,7 +725,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "06JtC2DC_dQ",
|
||||
name: "EEVblog 1490 - Insane Jaycar Dumpster Sale! 2022",
|
||||
duration: Some(1700),
|
||||
length: Some(1700),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/06JtC2DC_dQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDVIvEssIKji_8dyBYGYbpIqen7vQ",
|
||||
|
@ -699,7 +752,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -713,7 +766,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "piquT76w9TI",
|
||||
name: "EEVblog 1489 - Mystery Teardown!",
|
||||
duration: Some(1466),
|
||||
length: Some(1466),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/piquT76w9TI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCTzIcGeRDwUyINtik50EQCOTxwiA",
|
||||
|
@ -740,7 +793,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -754,7 +807,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "pKuUKT-zU-g",
|
||||
name: "EEVblog 1488 - Tilt Five Augmented Reality AR Glasses - First Reaction!",
|
||||
duration: Some(2152),
|
||||
length: Some(2152),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/pKuUKT-zU-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCB6Rh4miI20yPy2kJaxul_wA3Now",
|
||||
|
@ -781,7 +834,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -795,7 +848,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "_R4wQQNSO6k",
|
||||
name: "EEVblog 1487 - Do Solar Micro Inverters Take Power at Night?",
|
||||
duration: Some(2399),
|
||||
length: Some(2399),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/_R4wQQNSO6k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDEQVZ0yQPLZqwLdQednKWwLWqDmA",
|
||||
|
@ -822,7 +875,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -836,7 +889,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "ikp5BorIo_M",
|
||||
name: "EEVblog 1486 - What you DIDN\'T KNOW About Film Capacitor FAILURES!",
|
||||
duration: Some(1792),
|
||||
length: Some(1792),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/ikp5BorIo_M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBygGB8KC522NC15BhDC1WpuNKsgw",
|
||||
|
@ -863,7 +916,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -877,7 +930,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "7O-QckjCXNo",
|
||||
name: "eevBLAB 99 - AI SPAM BOT Youtube Space/Science/Tech Channels? - WTF",
|
||||
duration: Some(592),
|
||||
length: Some(592),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/7O-QckjCXNo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBY1cRnrWQCbmlAzP5okMmIYjgdsg",
|
||||
|
@ -904,7 +957,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -918,7 +971,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "VutdTxF4E-0",
|
||||
name: "RIP The Old Garage Lab",
|
||||
duration: Some(115),
|
||||
length: Some(115),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/VutdTxF4E-0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDlPpT2-UOGfm2A2djTLjCsygeqSw",
|
||||
|
@ -945,7 +998,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -959,7 +1012,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "o7xfGuRaq94",
|
||||
name: "EEVblog 1485 - PedalCell CadenceX Bike Generator LOL FAIL!",
|
||||
duration: Some(1026),
|
||||
length: Some(1026),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/o7xfGuRaq94/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBA7RRL2USBwkYXp9ouWTbtU-JHSg",
|
||||
|
@ -986,7 +1039,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1000,7 +1053,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "3WSIfHOv3fc",
|
||||
name: "EEVblog 1484 - Kaba Mas X-09 High Security Electronic Lock Teardown",
|
||||
duration: Some(1106),
|
||||
length: Some(1106),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/3WSIfHOv3fc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClZroFRo115ZuxYhJ5rcCDO2ZPcQ",
|
||||
|
@ -1027,7 +1080,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1041,7 +1094,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "8yXZJZCKImI",
|
||||
name: "EEVblog 1483 - Holy Mailbag Bomb Batman!",
|
||||
duration: Some(3373),
|
||||
length: Some(3373),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/8yXZJZCKImI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBym7WfmrvKIjs2ClW-FOLtxbENzw",
|
||||
|
@ -1068,7 +1121,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1082,7 +1135,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "vJ4pW6LKJWU",
|
||||
name: "EEVblog 1482 - Mains Capacitor Zener Regulator Circuit",
|
||||
duration: Some(1132),
|
||||
length: Some(1132),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/vJ4pW6LKJWU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDaKgfAJ4NAeqoMIPZDavsTw_JD5w",
|
||||
|
@ -1109,7 +1162,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1123,7 +1176,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "myqiqUE00fo",
|
||||
name: "EEVblog 1481 - Dodgy Dangerous Heater REPAIR",
|
||||
duration: Some(1622),
|
||||
length: Some(1622),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/myqiqUE00fo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB3nqRnunVeYPk1_vdXP7IEv1E1Rg",
|
||||
|
@ -1150,7 +1203,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1164,7 +1217,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "xIokNnjuam8",
|
||||
name: "EEVblog 1480 - Lightyear Zero Solar Powered Electric Car",
|
||||
duration: Some(1196),
|
||||
length: Some(1196),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/xIokNnjuam8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBRxCpyCftz0LJooMtxBcIWwaF6hw",
|
||||
|
@ -1191,7 +1244,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1205,7 +1258,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "S3R4r2xvVYQ",
|
||||
name: "EEVblog 1479 - Is Your Calculator WRONG?",
|
||||
duration: Some(1066),
|
||||
length: Some(1066),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/S3R4r2xvVYQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC2ZW-UUXJGrtHphT2E53pFafr-1g",
|
||||
|
@ -1232,7 +1285,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1246,7 +1299,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "RlwcdUnRw6w",
|
||||
name: "EEVblog 1478 - Waveform Update Rate Shootout - Tek 2 Series vs Others",
|
||||
duration: Some(1348),
|
||||
length: Some(1348),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/RlwcdUnRw6w/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYaH7c8-BP8807GgNGML2WUNK8pg",
|
||||
|
@ -1273,7 +1326,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
@ -1287,7 +1340,7 @@ Channel(
|
|||
VideoItem(
|
||||
id: "R2fw2g6WFbg",
|
||||
name: "EEVblog 1477 - TEARDOWN! - NEW Tektronix 2 Series Oscilloscope",
|
||||
duration: Some(2718),
|
||||
length: Some(2718),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/R2fw2g6WFbg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBwd6wqvFI0HcPpOkDW_XDzWSPH_w",
|
||||
|
@ -1314,7 +1367,7 @@ Channel(
|
|||
id: "UC2DjFE7Xf11URZqWBigcVOQ",
|
||||
name: "EEVblog",
|
||||
avatar: [],
|
||||
verification: verified,
|
||||
verification: Verified,
|
||||
subscriber_count: Some(880000),
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
|
|
|
@ -5,9 +5,7 @@ expression: map_res.c
|
|||
Channel(
|
||||
id: "UCxBa895m48H5idw5li7h-0g",
|
||||
name: "Sebastian Figurroa",
|
||||
handle: None,
|
||||
subscriber_count: None,
|
||||
video_count: None,
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/ytc/AMLnZu_hsZ1XlUXHzXsGNHJw0np79WhWZcC4j8eFdy-tiUCDBKAjJyJOzE5kXFRiqL2S=s48-c-k-c0x00ffffff-no-rj",
|
||||
|
@ -25,10 +23,13 @@ Channel(
|
|||
height: 176,
|
||||
),
|
||||
],
|
||||
verification: none,
|
||||
verification: None,
|
||||
description: "",
|
||||
tags: [],
|
||||
vanity_url: None,
|
||||
banner: [],
|
||||
mobile_banner: [],
|
||||
tv_banner: [],
|
||||
has_shorts: false,
|
||||
has_live: false,
|
||||
visitor_data: Some("Cgtvc2s4UllvTGl6byigxseZBg%3D%3D"),
|
||||
|
|