Compare commits
	
		
			1 commit
		
	
	
		
			
				main
			
			...
			
				feat/chann
			
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a2f9e87154 | 
|  | @ -1,68 +0,0 @@ | ||||||
| name: CI |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     branches: ["main"] |  | ||||||
|   pull_request: |  | ||||||
| 
 |  | ||||||
| jobs: |  | ||||||
|   Test: |  | ||||||
|     runs-on: cimaster-latest |  | ||||||
|     services: |  | ||||||
|       warpproxy: |  | ||||||
|         image: thetadev256/warpproxy |  | ||||||
|         env: |  | ||||||
|           WARP_DEVICE_ID: ${{ secrets.WARP_DEVICE_ID }} |  | ||||||
|           WARP_ACCESS_TOKEN: ${{ secrets.WARP_ACCESS_TOKEN }} |  | ||||||
|           WARP_LICENSE_KEY: ${{ secrets.WARP_LICENSE_KEY }} |  | ||||||
|           WARP_PRIVATE_KEY: ${{ secrets.WARP_PRIVATE_KEY }} |  | ||||||
|     steps: |  | ||||||
|       - name: 📦 Checkout repository |  | ||||||
|         uses: actions/checkout@v4 |  | ||||||
| 
 |  | ||||||
|       - name: 🦀 Setup Rust cache |  | ||||||
|         uses: https://github.com/Swatinem/rust-cache@v2 |  | ||||||
|         with: |  | ||||||
|           cache-on-failure: "true" |  | ||||||
| 
 |  | ||||||
|       - name: Download rustypipe-botguard |  | ||||||
|         run: | |  | ||||||
|           TARGET=$(rustc --version --verbose | grep "host:" | sed -e 's/^host: //') |  | ||||||
|           cd ~ |  | ||||||
|           curl -SsL -o rustypipe-botguard.tar.xz "https://codeberg.org/ThetaDev/rustypipe-botguard/releases/download/v0.1.1/rustypipe-botguard-v0.1.1-${TARGET}.tar.xz" |  | ||||||
|           cd /usr/local/bin |  | ||||||
|           sudo tar -xJf ~/rustypipe-botguard.tar.xz |  | ||||||
|           rm ~/rustypipe-botguard.tar.xz |  | ||||||
|           rustypipe-botguard --version |  | ||||||
| 
 |  | ||||||
|       - name: 📎 Clippy |  | ||||||
|         run: | |  | ||||||
|           cargo clippy --all --tests --features=rss,userdata,indicatif,audiotag -- -D warnings |  | ||||||
|           cargo clippy --package=rustypipe --tests -- -D warnings |  | ||||||
|           cargo clippy --package=rustypipe-downloader -- -D warnings |  | ||||||
|           cargo clippy --package=rustypipe-cli -- -D warnings |  | ||||||
|           cargo clippy --package=rustypipe-cli --features=timezone -- -D warnings |  | ||||||
| 
 |  | ||||||
|       - name: 🧪 Test |  | ||||||
|         run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 --features rss,userdata --workspace -- --skip 'user_data::' |  | ||||||
|         env: |  | ||||||
|           ALL_PROXY: "http://warpproxy:8124" |  | ||||||
| 
 |  | ||||||
|       - name: Move test report |  | ||||||
|         if: always() |  | ||||||
|         run: mv target/nextest/ci/junit.xml junit.xml || true |  | ||||||
| 
 |  | ||||||
|       - name: 💌 Upload test report |  | ||||||
|         if: always() |  | ||||||
|         uses: https://code.forgejo.org/forgejo/upload-artifact@v4 |  | ||||||
|         with: |  | ||||||
|           name: test |  | ||||||
|           path: | |  | ||||||
|             junit.xml |  | ||||||
|             rustypipe_reports |  | ||||||
| 
 |  | ||||||
|       - name: 🔗 Artifactview PR comment |  | ||||||
|         if: ${{ always() && github.event_name == 'pull_request' }} |  | ||||||
|         run: | |  | ||||||
|           if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi |  | ||||||
|           curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \ |  | ||||||
|             --data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}' |  | ||||||
|  | @ -1,69 +0,0 @@ | ||||||
| name: Release CLI |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     tags: |  | ||||||
|       - "rustypipe-cli/v*.*.*" |  | ||||||
| 
 |  | ||||||
| jobs: |  | ||||||
|   Release: |  | ||||||
|     runs-on: cimaster-latest |  | ||||||
|     steps: |  | ||||||
|       - name: 📦 Checkout repository |  | ||||||
|         uses: actions/checkout@v4 |  | ||||||
| 
 |  | ||||||
|       - name: Setup cross compilation |  | ||||||
|         run: | |  | ||||||
|           rustup target add x86_64-pc-windows-msvc x86_64-apple-darwin aarch64-apple-darwin |  | ||||||
|           cargo install cargo-xwin |  | ||||||
| 
 |  | ||||||
|           # https://wapl.es/rust/2019/02/17/rust-cross-compile-linux-to-macos.html/ |  | ||||||
|           sudo apt-get install -y llvm clang cmake |  | ||||||
|           cd ~ |  | ||||||
|           git clone https://github.com/tpoechtrager/osxcross |  | ||||||
|           cd osxcross |  | ||||||
|           wget -nc "https://github.com/joseluisq/macosx-sdks/releases/download/12.3/MacOSX12.3.sdk.tar.xz" |  | ||||||
|           mv MacOSX12.3.sdk.tar.xz tarballs/ |  | ||||||
|           UNATTENDED=yes OSX_VERSION_MIN=12.3 ./build.sh |  | ||||||
|           OSXCROSS_BIN="$(pwd)/target/bin" |  | ||||||
| 
 |  | ||||||
|           echo "CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=$(find "$OSXCROSS_BIN" -name "x86_64-apple-darwin*-clang")" >> $GITHUB_ENV |  | ||||||
|           echo "CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=-Car=$(find "$OSXCROSS_BIN" -name "x86_64-apple-darwin*-ar"),-Clink-arg=-undefined,-Clink-arg=dynamic_lookup" >> $GITHUB_ENV |  | ||||||
|           echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=$(find "$OSXCROSS_BIN" -name "aarch64-apple-darwin*-clang")" >> $GITHUB_ENV |  | ||||||
|           echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=-Car=$(find "$OSXCROSS_BIN" -name "aarch64-apple-darwin*-ar"),-Clink-arg=-undefined,-Clink-arg=dynamic_lookup" >> $GITHUB_ENV |  | ||||||
| 
 |  | ||||||
|       - name: ⚒️ Build application |  | ||||||
|         run: | |  | ||||||
|           export PATH="$PATH:$HOME/osxcross/target/bin" |  | ||||||
|           CRATE="rustypipe-cli" |  | ||||||
|           PKG_CONFIG_SYSROOT_DIR=/usr/x86_64-linux-gnu cargo build --release --package=$CRATE --target x86_64-unknown-linux-gnu |  | ||||||
|           PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu cargo build --release --package=$CRATE --target aarch64-unknown-linux-gnu |  | ||||||
|           CC="$CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER" CXX="$CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER++" cargo build --release --package=$CRATE --target x86_64-apple-darwin |  | ||||||
|           CC="$CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER" CXX="$CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER++" cargo build --release --package=$CRATE --target aarch64-apple-darwin |  | ||||||
|           cargo xwin build --release --package=$CRATE --target x86_64-pc-windows-msvc |  | ||||||
| 
 |  | ||||||
|       - name: Prepare release |  | ||||||
|         run: | |  | ||||||
|           CRATE="rustypipe-cli" |  | ||||||
|           BIN="rustypipe" |  | ||||||
|           echo "CRATE=$CRATE" >> "$GITHUB_ENV" |  | ||||||
|           echo "CRATE_VERSION=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==2{print}')" >> "$GITHUB_ENV" |  | ||||||
|           CL_PATH="cli/CHANGELOG.md" |  | ||||||
|           { |  | ||||||
|             echo 'CHANGELOG<<END_OF_FILE' |  | ||||||
|             awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CL_PATH" |  | ||||||
|             echo END_OF_FILE |  | ||||||
|           } >> "$GITHUB_ENV" |  | ||||||
| 
 |  | ||||||
|           mkdir dist |  | ||||||
| 
 |  | ||||||
|           for arch in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin; do |  | ||||||
|             tar -cJf "dist/${BIN}-${CRATE_VERSION}-${arch}.tar.xz" -C target/${arch}/release "${BIN}" |  | ||||||
|           done |  | ||||||
|           (cd target/x86_64-pc-windows-msvc/release && zip -9 "../../../dist/${BIN}-${CRATE_VERSION}-x86_64-pc-windows-msvc.zip" "${BIN}.exe") |  | ||||||
| 
 |  | ||||||
|       - name: 🎉 Publish release |  | ||||||
|         uses: https://gitea.com/actions/release-action@main |  | ||||||
|         with: |  | ||||||
|           title: "${{ env.CRATE }} ${{ env.CRATE_VERSION }}" |  | ||||||
|           body: "${{ env.CHANGELOG }}" |  | ||||||
|           files: dist/* |  | ||||||
|  | @ -1,34 +0,0 @@ | ||||||
| name: Release |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     tags: |  | ||||||
|       - "*/v*.*.*" |  | ||||||
| 
 |  | ||||||
| jobs: |  | ||||||
|   Release: |  | ||||||
|     runs-on: cimaster-latest |  | ||||||
|     steps: |  | ||||||
|       - name: 📦 Checkout repository |  | ||||||
|         uses: actions/checkout@v4 |  | ||||||
| 
 |  | ||||||
|       - name: Get variables |  | ||||||
|         run: | |  | ||||||
|           CRATE=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==1{print}') |  | ||||||
|           echo "CRATE=$CRATE" >> "$GITHUB_ENV" |  | ||||||
|           echo "CRATE_VERSION=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==2{print}')" >> "$GITHUB_ENV" |  | ||||||
|           CL_PATH="CHANGELOG.md" |  | ||||||
|           if [[ "$CRATE" != "rustypipe" ]]; then pfx="rustypipe-"; CL_PATH="${CRATE#"$pfx"}/$CL_PATH"; fi |  | ||||||
|           { |  | ||||||
|             echo 'CHANGELOG<<END_OF_FILE' |  | ||||||
|             awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CL_PATH" |  | ||||||
|             echo END_OF_FILE |  | ||||||
|           } >> "$GITHUB_ENV" |  | ||||||
| 
 |  | ||||||
|       - name: 📤 Publish crate on crates.io |  | ||||||
|         run: cargo publish --token ${{ secrets.CARGO_TOKEN }} --package "${{ env.CRATE }}" |  | ||||||
| 
 |  | ||||||
|       - name: 🎉 Publish release |  | ||||||
|         uses: https://gitea.com/actions/release-action@main |  | ||||||
|         with: |  | ||||||
|           title: "${{ env.CRATE }} ${{ env.CRATE_VERSION }}" |  | ||||||
|           body: "${{ env.CHANGELOG }}" |  | ||||||
|  | @ -1,63 +0,0 @@ | ||||||
| name: renovate |  | ||||||
| 
 |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     branches: ["main"] |  | ||||||
|     paths: |  | ||||||
|       - ".forgejo/workflows/renovate.yaml" |  | ||||||
|       - "renovate.json" |  | ||||||
|   schedule: |  | ||||||
|     - cron: "0 0 * * *" |  | ||||||
|   workflow_dispatch: |  | ||||||
| 
 |  | ||||||
| env: |  | ||||||
|   RENOVATE_REPOSITORIES: ${{ github.repository }} |  | ||||||
| 
 |  | ||||||
| jobs: |  | ||||||
|   renovate: |  | ||||||
|     runs-on: docker |  | ||||||
|     container: |  | ||||||
|       image: renovate/renovate:39 |  | ||||||
| 
 |  | ||||||
|     steps: |  | ||||||
|       - name: Load renovate repo cache |  | ||||||
|         uses: actions/cache/restore@v4 |  | ||||||
|         with: |  | ||||||
|           path: | |  | ||||||
|             .tmp/cache/renovate/repository |  | ||||||
|             .tmp/cache/renovate/renovate-cache-sqlite |  | ||||||
|             .tmp/osv |  | ||||||
|           key: repo-cache-${{ github.run_id }} |  | ||||||
|           restore-keys: | |  | ||||||
|             repo-cache- |  | ||||||
| 
 |  | ||||||
|       - name: Run renovate |  | ||||||
|         run: renovate |  | ||||||
|         env: |  | ||||||
|           LOG_LEVEL: debug |  | ||||||
|           RENOVATE_BASE_DIR: ${{ github.workspace }}/.tmp |  | ||||||
|           RENOVATE_ENDPOINT: ${{ github.server_url }} |  | ||||||
|           RENOVATE_PLATFORM: gitea |  | ||||||
|           RENOVATE_REPOSITORY_CACHE: 'enabled' |  | ||||||
|           RENOVATE_TOKEN: ${{ secrets.FORGEJO_CI_BOT_TOKEN }} |  | ||||||
|           GITHUB_COM_TOKEN: ${{ secrets.GH_PUBLIC_TOKEN }} |  | ||||||
|           RENOVATE_GIT_AUTHOR: 'Renovate Bot <forgejo-renovate-action@forgejo.org>' |  | ||||||
| 
 |  | ||||||
|           RENOVATE_X_SQLITE_PACKAGE_CACHE: true |  | ||||||
| 
 |  | ||||||
|           GIT_AUTHOR_NAME: 'Renovate Bot' |  | ||||||
|           GIT_AUTHOR_EMAIL: 'forgejo-renovate-action@forgejo.org' |  | ||||||
|           GIT_COMMITTER_NAME: 'Renovate Bot' |  | ||||||
|           GIT_COMMITTER_EMAIL: 'forgejo-renovate-action@forgejo.org' |  | ||||||
| 
 |  | ||||||
|           OSV_OFFLINE_ROOT_DIR: ${{ github.workspace }}/.tmp/osv |  | ||||||
| 
 |  | ||||||
|       - name: Save renovate repo cache |  | ||||||
|         if: always() && env.RENOVATE_DRY_RUN != 'full' |  | ||||||
|         uses: actions/cache/save@v4 |  | ||||||
|         with: |  | ||||||
|           path: | |  | ||||||
|             .tmp/cache/renovate/repository |  | ||||||
|             .tmp/cache/renovate/renovate-cache-sqlite |  | ||||||
|             .tmp/osv |  | ||||||
|           key: repo-cache-${{ github.run_id }} |  | ||||||
							
								
								
									
										3
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						|  | @ -4,5 +4,4 @@ | ||||||
| *.snap.new | *.snap.new | ||||||
| 
 | 
 | ||||||
| rustypipe_reports | rustypipe_reports | ||||||
| rustypipe_cache*.json | rustypipe_cache.json | ||||||
| bg_snapshot.bin |  | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| repos: | repos: | ||||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks |   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||||
|     rev: v5.0.0 |     rev: v4.3.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: end-of-file-fixer |       - id: end-of-file-fixer | ||||||
|       - id: check-json |       - id: check-json | ||||||
|  | @ -10,8 +10,4 @@ repos: | ||||||
|     hooks: |     hooks: | ||||||
|       - id: cargo-fmt |       - id: cargo-fmt | ||||||
|       - id: cargo-clippy |       - id: cargo-clippy | ||||||
|         name: cargo-clippy rustypipe |         args: ["--all", "--all-features", "--", "-D", "warnings"] | ||||||
|         args: ["--package=rustypipe", "--tests", "--", "-D", "warnings"] |  | ||||||
|       - id: cargo-clippy |  | ||||||
|         name: cargo-clippy workspace |  | ||||||
|         args: ["--all", "--tests", "--features=rss,userdata,indicatif,audiotag", "--", "-D", "warnings"] |  | ||||||
|  |  | ||||||
							
								
								
									
										10
									
								
								.woodpecker.yml
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,10 @@ | ||||||
|  | pipeline: | ||||||
|  |   test: | ||||||
|  |     image: rust:latest | ||||||
|  |     environment: | ||||||
|  |       - CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse | ||||||
|  |     commands: | ||||||
|  |       - rustup component add rustfmt clippy | ||||||
|  |       - cargo fmt --all --check | ||||||
|  |       - cargo clippy --all --all-features -- -D warnings | ||||||
|  |       - cargo test --workspace | ||||||
							
								
								
									
										396
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,396 +0,0 @@ | ||||||
| # Changelog |  | ||||||
| 
 |  | ||||||
| All notable changes to this project will be documented in this file. |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.11.4](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.11.3..rustypipe/v0.11.4) - 2025-04-23 |  | ||||||
| 
 |  | ||||||
| ### 🚀 Features |  | ||||||
| 
 |  | ||||||
| - Player: handle VPN ban and captcha required error messages - ([be6da5e](https://codeberg.org/ThetaDev/rustypipe/commit/be6da5e7e3558ef39773bf45bcb8afbf006bacec)) |  | ||||||
| 
 |  | ||||||
| ### 🐛 Bug Fixes |  | ||||||
| 
 |  | ||||||
| - Deobfuscator: handle 1-char long global variables, find nsig fn (player 6450230e) - ([d675987](https://codeberg.org/ThetaDev/rustypipe/commit/d675987654972c6aa4cc2b291d25bc49fa60173e)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.11.3](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.11.2..rustypipe/v0.11.3) - 2025-04-03 |  | ||||||
| 
 |  | ||||||
| ### 🐛 Bug Fixes |  | ||||||
| 
 |  | ||||||
| - Deobfuscator: global variable extraction fixed - ([ac44e95](https://codeberg.org/ThetaDev/rustypipe/commit/ac44e95a88d95f9d2d1ec672f86ca9d31d6991b9)) |  | ||||||
| - Deobfuscator: small simplification - ([189ba81](https://codeberg.org/ThetaDev/rustypipe/commit/189ba81a42e6c09f6af4d2768c449c22b864101e)) |  | ||||||
| - Deobfuscator: handle global functions as well - ([939a7ae](https://codeberg.org/ThetaDev/rustypipe/commit/939a7aea61a3eee4c1e67bfbfc835f0ce3934171)) |  | ||||||
| - Handle music playlist/album not found - ([ea80717](https://codeberg.org/ThetaDev/rustypipe/commit/ea80717f692b2c45b5063c362c9fa8ebca5a3471)) |  | ||||||
| - Switch client if no adaptive stream URLs were returned - ([187bf1c](https://codeberg.org/ThetaDev/rustypipe/commit/187bf1c9a0e846bff205e0d71a19c5a1ce7b1943)) |  | ||||||
| - Handle music artist not found - ([daf3d03](https://codeberg.org/ThetaDev/rustypipe/commit/daf3d035be38b59aef1ae205ac91c2bbdda2fe66)) |  | ||||||
| 
 |  | ||||||
| ### ⚙️ Miscellaneous Tasks |  | ||||||
| 
 |  | ||||||
| - *(deps)* Update rust crate rand to 0.9.0 - ([af415dd](https://codeberg.org/ThetaDev/rustypipe/commit/af415ddf8f94f00edb918f271d8e6336503e9faf)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.11.2](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.11.1..rustypipe/v0.11.2) - 2025-03-24 |  | ||||||
| 
 |  | ||||||
| ### 🐛 Bug Fixes |  | ||||||
| 
 |  | ||||||
| - A/B test 22: commandExecutorCommand for playlist continuations - ([e8acbfb](https://codeberg.org/ThetaDev/rustypipe/commit/e8acbfbbcf5d31b5ac34410ddf334e5534e3762f)) |  | ||||||
| - Extract deobf data with global strings variable - ([4ce6746](https://codeberg.org/ThetaDev/rustypipe/commit/4ce6746be538564e79f7e3c67d7a91aaa53f48ea)) |  | ||||||
| - Handle player returning no adaptive stream URLs - ([07db7b1](https://codeberg.org/ThetaDev/rustypipe/commit/07db7b1166e912e1554f98f2ae20c2c356fed38f)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.11.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.11.0..rustypipe/v0.11.1) - 2025-03-16 |  | ||||||
| 
 |  | ||||||
| ### 🐛 Bug Fixes |  | ||||||
| 
 |  | ||||||
| - Simplify get_player_from_clients logic - ([c04b606](https://codeberg.org/ThetaDev/rustypipe/commit/c04b60604d2628bf8f0e3de453c243adbb966e57)) |  | ||||||
| - Desktop client: generate PO token from user_syncid when authenticated - ([8342cae](https://codeberg.org/ThetaDev/rustypipe/commit/8342caeb0f566a38060a6ec69f3ca65b9a2afcd6)) |  | ||||||
| - Always skip failed clients - ([63a6f50](https://codeberg.org/ThetaDev/rustypipe/commit/63a6f50a8b5ad6bb984282335c1481ae3cd2fe83)) |  | ||||||
| 
 |  | ||||||
| ### ⚙️ Miscellaneous Tasks |  | ||||||
| 
 |  | ||||||
| - *(deps)* Update rust crate rstest to 0.25.0 - ([9ed1306](https://codeberg.org/ThetaDev/rustypipe/commit/9ed1306f3aaeb993c409997ddfbc47499e4f4d22)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.11.0](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe/v0.10.0..rustypipe/v0.11.0) - 2025-02-26 |  | ||||||
| 
 |  | ||||||
| ### 🚀 Features |  | ||||||
| 
 |  | ||||||
| - Add original album track count, fix fetching albums with more than 200 tracks - ([544782f](https://codeberg.org/ThetaDev/rustypipe/commit/544782f8de728cda0aca9a1cb95837cdfbd001f1)) |  | ||||||
| 
 |  | ||||||
| ### 🐛 Bug Fixes |  | ||||||
| 
 |  | ||||||
| - A/B test 21: music album recommendations - ([6737512](https://codeberg.org/ThetaDev/rustypipe/commit/6737512f5f67c8cd05d4552dd0e0f24381035b35)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [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 --> |  | ||||||
							
								
								
									
										158
									
								
								Cargo.toml
									
										
									
									
									
								
							
							
						
						|  | @ -1,132 +1,64 @@ | ||||||
| [package] | [package] | ||||||
| name = "rustypipe" | name = "rustypipe" | ||||||
| version = "0.11.4" | version = "0.1.0" | ||||||
| rust-version = "1.67.1" | edition = "2021" | ||||||
| edition.workspace = true | authors = ["ThetaDev <t.testboy@gmail.com>"] | ||||||
| authors.workspace = true | license = "GPL-3.0" | ||||||
| license.workspace = true |  | ||||||
| repository.workspace = true |  | ||||||
| keywords.workspace = true |  | ||||||
| categories.workspace = true |  | ||||||
| description = "Client for the public YouTube / YouTube Music API (Innertube), inspired by NewPipe" | description = "Client for the public YouTube / YouTube Music API (Innertube), inspired by NewPipe" | ||||||
|  | keywords = ["youtube", "video", "music"] | ||||||
| 
 | 
 | ||||||
| include = ["/src", "README.md", "CHANGELOG.md", "LICENSE", "!snapshots"] | include = ["/src", "README.md", "LICENSE", "!snapshots"] | ||||||
| 
 | 
 | ||||||
| [workspace] | [workspace] | ||||||
| members = [".", "codegen", "downloader", "cli"] | members = [".", "codegen", "downloader", "cli"] | ||||||
| 
 | 
 | ||||||
| [workspace.package] |  | ||||||
| edition = "2021" |  | ||||||
| authors = ["ThetaDev <thetadev@magenta.de>"] |  | ||||||
| license = "GPL-3.0" |  | ||||||
| repository = "https://codeberg.org/ThetaDev/rustypipe" |  | ||||||
| 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.9.0" |  | ||||||
| time = { version = "0.3.37", features = [ |  | ||||||
|     "macros", |  | ||||||
|     "serde-human-readable", |  | ||||||
|     "serde-well-known", |  | ||||||
|     "local-offset", |  | ||||||
| ] } |  | ||||||
| futures-util = "0.3.31" |  | ||||||
| ress = "0.11.0" |  | ||||||
| phf = "0.11.0" |  | ||||||
| phf_codegen = "0.11.0" |  | ||||||
| data-encoding = "2.0.0" |  | ||||||
| urlencoding = "2.1.0" |  | ||||||
| quick-xml = { version = "0.37.0", features = ["serialize"] } |  | ||||||
| tracing = { version = "0.1.0", features = ["log"] } |  | ||||||
| localzone = "0.3.1" |  | ||||||
| 
 |  | ||||||
| # CLI |  | ||||||
| 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.25.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.11.4", default-features = false } |  | ||||||
| rustypipe-downloader = { path = "./downloader", version = "0.3.1", default-features = false, features = [ |  | ||||||
|     "indicatif", |  | ||||||
|     "audiotag", |  | ||||||
| ] } |  | ||||||
| 
 |  | ||||||
| [features] | [features] | ||||||
| default = ["default-tls"] | default = ["default-tls"] | ||||||
| 
 | 
 | ||||||
| rss = ["dep:quick-xml"] | rss = ["quick-xml"] | ||||||
| userdata = [] |  | ||||||
| 
 | 
 | ||||||
| # Reqwest TLS options | # Reqwest TLS | ||||||
| default-tls = ["reqwest/default-tls"] | default-tls = ["reqwest/default-tls"] | ||||||
| native-tls = ["reqwest/native-tls"] |  | ||||||
| native-tls-alpn = ["reqwest/native-tls-alpn"] |  | ||||||
| native-tls-vendored = ["reqwest/native-tls-vendored"] |  | ||||||
| rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] | rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] | ||||||
| rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] | rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] | ||||||
| 
 | 
 | ||||||
| [dependencies] | [dependencies] | ||||||
| rquickjs.workspace = true | quick-js-dtp = { version = "0.4.1", default-features = false, features = [ | ||||||
| once_cell.workspace = true |     "patch-dateparser", | ||||||
| regex.workspace = true | ] } | ||||||
| fancy-regex.workspace = true | once_cell = "1.12.0" | ||||||
| thiserror.workspace = true | regex = "1.6.0" | ||||||
| url.workspace = true | fancy-regex = "0.11.0" | ||||||
| reqwest = { workspace = true, features = ["json", "gzip", "brotli"] } | thiserror = "1.0.36" | ||||||
| tokio = { workspace = true, features = ["macros", "time", "process"] } | url = "2.2.2" | ||||||
| serde.workspace = true | log = "0.4.17" | ||||||
| serde_json.workspace = true | reqwest = { version = "0.11.11", default-features = false, features = [ | ||||||
| serde_with.workspace = true |     "json", | ||||||
| serde_plain.workspace = true |     "gzip", | ||||||
| sha1.workspace = true |     "brotli", | ||||||
| rand.workspace = true | ] } | ||||||
| time.workspace = true | tokio = { version = "1.20.0", features = ["macros", "time"] } | ||||||
| ress.workspace = true | serde = { version = "1.0", features = ["derive"] } | ||||||
| phf.workspace = true | serde_json = "1.0.82" | ||||||
| data-encoding.workspace = true | serde_with = { version = "2.0.0", features = ["json"] } | ||||||
| urlencoding.workspace = true | rand = "0.8.5" | ||||||
| tracing.workspace = true | time = { version = "0.3.15", features = [ | ||||||
| localzone.workspace = true |     "macros", | ||||||
| quick-xml = { workspace = true, optional = true } |     "serde", | ||||||
|  |     "serde-well-known", | ||||||
|  | ] } | ||||||
|  | futures = "0.3.21" | ||||||
|  | ress = "0.11.4" | ||||||
|  | phf = "0.11.1" | ||||||
|  | base64 = "0.21.0" | ||||||
|  | urlencoding = "2.1.2" | ||||||
|  | quick-xml = { version = "0.28.1", features = ["serialize"], optional = true } | ||||||
| 
 | 
 | ||||||
| [dev-dependencies] | [dev-dependencies] | ||||||
| rstest.workspace = true | env_logger = "0.10.0" | ||||||
| tokio-test.workspace = true | test-log = "0.2.11" | ||||||
| insta.workspace = true | rstest = "0.17.0" | ||||||
| path_macro.workspace = true | temp_testdir = "0.2.3" | ||||||
| tracing-test.workspace = true | tokio-test = "0.4.2" | ||||||
| 
 | insta = { version = "1.17.1", features = ["ron", "redactions"] } | ||||||
| [package.metadata.docs.rs] | path_macro = "1.0.0" | ||||||
| # To build locally: |  | ||||||
| # RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features rss,userdata --no-deps --open |  | ||||||
| features = ["rss", "userdata"] |  | ||||||
| rustdoc-args = ["--cfg", "docsrs"] |  | ||||||
|  |  | ||||||
|  | @ -1,26 +0,0 @@ | ||||||
| ## Development |  | ||||||
| 
 |  | ||||||
| **Requirements:** |  | ||||||
| 
 |  | ||||||
| - Current version of stable Rust |  | ||||||
| - [`just`](https://github.com/casey/just) task runner |  | ||||||
| - [`nextest`](https://nexte.st) test runner |  | ||||||
| - [`pre-commit`](https://pre-commit.com/) |  | ||||||
| - yq (YAML processor) |  | ||||||
| 
 |  | ||||||
| ### Tasks |  | ||||||
| 
 |  | ||||||
| **Testing** |  | ||||||
| 
 |  | ||||||
| - `just test` Run unit+integration tests |  | ||||||
| - `just unittest` Run unit tests |  | ||||||
| - `just testyt` Run YouTube integration tests |  | ||||||
| - `just testintl` Run YouTube integration tests for all supported languages (this takes |  | ||||||
|   a long time and is therefore not run in CI) |  | ||||||
| - `YT_LANG=de just testyt` Run YouTube integration tests for a specific language |  | ||||||
| 
 |  | ||||||
| **Tools** |  | ||||||
| 
 |  | ||||||
| - `just testfiles` Download missing testfiles for unit tests |  | ||||||
| - `just report2yaml` Convert RustyPipe reports into a more readable yaml format |  | ||||||
|   (requires `yq`) |  | ||||||
							
								
								
									
										89
									
								
								Justfile
									
										
									
									
									
								
							
							
						
						|  | @ -1,92 +1,23 @@ | ||||||
| test: | test: | ||||||
|     # cargo test --features=rss,userdata |     cargo test --all-features | ||||||
|     cargo nextest run --workspace --features=rss,userdata --no-fail-fast --retries 1 -- --skip 'user_data::' |  | ||||||
| 
 | 
 | ||||||
| unittest: | unittest: | ||||||
|     cargo nextest run --features=rss,userdata --no-fail-fast --lib |     cargo test --all-features --lib | ||||||
| 
 | 
 | ||||||
| testyt: | testyt: | ||||||
|     cargo nextest run --features=rss,userdata --no-fail-fast --retries 1 --test youtube -- --skip 'user_data::' |     cargo test --all-features --test youtube | ||||||
| 
 | 
 | ||||||
| testyt-cookie: | testyt10: | ||||||
|     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' |  | ||||||
| 
 |  | ||||||
| testintl: |  | ||||||
|     #!/usr/bin/env bash |     #!/usr/bin/env bash | ||||||
|     LANGUAGES=( |     set -e | ||||||
|         "af" "am" "ar" "as" "az" "be" "bg" "bn" "bs" "ca" "cs" "da" "de" "el" |     for i in {1..10}; do \
 | ||||||
|         "en" "en-GB" "en-IN" |         echo "---TEST RUN $i---"; \
 | ||||||
|         "es" "es-419" "es-US" "et" "eu" "fa" "fi" "fil" "fr" "fr-CA" "gl" "gu" |         cargo test --all-features --test youtube; \
 | ||||||
|         "hi" "hr" "hu" "hy" "id" "is" "it" "iw" "ja" "ka" "kk" "km" "kn" "ko" "ky" |  | ||||||
|         "lo" "lt" "lv" "mk" "ml" "mn" "mr" "ms" "my" "ne" "nl" "no" "or" "pa" "pl" |  | ||||||
|         "pt" "pt-PT" "ro" "ru" "si" "sk" "sl" "sq" "sr" "sr-Latn" "sv" "sw" "ta" |  | ||||||
|         "te" "th" "tr" "uk" "ur" "uz" "vi" "zh-CN" "zh-HK" "zh-TW" "zu" |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     N_FAILED=0 |  | ||||||
| 
 |  | ||||||
|     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 |  | ||||||
|             echo "--- $YT_LANG COMPLETED ---" |  | ||||||
|         else |  | ||||||
|             echo "--- $YT_LANG FAILED ---" |  | ||||||
|             ((N_FAILED++)) |  | ||||||
|         fi |  | ||||||
|     done |     done | ||||||
| 
 | 
 | ||||||
|     exit "$N_FAILED" |  | ||||||
| 
 |  | ||||||
| testfiles: | testfiles: | ||||||
|     cargo run -p rustypipe-codegen download-testfiles |     cargo run -p rustypipe-codegen -- -d . download-testfiles | ||||||
| 
 | 
 | ||||||
| report2yaml: | report2yaml: | ||||||
|     mkdir -p rustypipe_reports/conv |     mkdir -p rustypipe_reports/conv | ||||||
|     for f in rustypipe_reports/*.json; do yq '.http_request.resp_body' $f | yq -o json -P > rustypipe_reports/conv/`basename $f .json`_body.json; yq e -Pi "del(.http_request.resp_body)" $f; mv $f rustypipe_reports/conv/`basename $f .json`.yaml; done; |     for f in rustypipe_reports/*.json; do yq '.http_request.resp_body' $f | yq -o json -P > rustypipe_reports/conv/`basename $f .json`_body.json; yq e -Pi $f; mv $f rustypipe_reports/conv/`basename $f .json`.yaml; done; | ||||||
| 
 |  | ||||||
| release crate="rustypipe": |  | ||||||
|     #!/usr/bin/env bash |  | ||||||
|     set -e |  | ||||||
| 
 |  | ||||||
|     CRATE="{{crate}}" |  | ||||||
|     CHANGELOG="CHANGELOG.md" |  | ||||||
| 
 |  | ||||||
|     if [ "$CRATE" = "rustypipe" ]; then |  | ||||||
|         INCLUDES="--exclude-path 'notes/**' --exclude-path 'cli/**' --exclude-path 'downloader/**'" |  | ||||||
|     else |  | ||||||
|         if [ ! -d "$CRATE" ]; then |  | ||||||
|             echo "$CRATE does not exist."; exit 1 |  | ||||||
|         fi |  | ||||||
|         INCLUDES="--include-path README.md --include-path LICENSE --include-path Cargo.toml --include-path '$CRATE/**'" |  | ||||||
|         CHANGELOG="$CRATE/$CHANGELOG" |  | ||||||
|         CRATE="rustypipe-$CRATE" # Add crate name prefix |  | ||||||
|     fi |  | ||||||
| 
 |  | ||||||
|     VERSION=$(cargo pkgid --package "$CRATE" | tr '#@' '\n' | tail -n 1) |  | ||||||
|     TAG="${CRATE}/v${VERSION}" |  | ||||||
|     echo "Releasing $TAG:" |  | ||||||
| 
 |  | ||||||
|     if git rev-parse "$TAG" >/dev/null 2>&1; then echo "version tag $TAG already exists"; exit 1; fi |  | ||||||
| 
 |  | ||||||
|     CLIFF_ARGS="--tag '${TAG}' --tag-pattern '${CRATE}/v*' --unreleased $INCLUDES" |  | ||||||
|     echo "git-cliff $CLIFF_ARGS" |  | ||||||
|     if [ -f "$CHANGELOG" ]; then |  | ||||||
|         eval "git-cliff $CLIFF_ARGS --prepend '$CHANGELOG'" |  | ||||||
|     else |  | ||||||
|         eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'" |  | ||||||
|     fi |  | ||||||
| 
 |  | ||||||
|     editor "$CHANGELOG" |  | ||||||
| 
 |  | ||||||
|     git add . |  | ||||||
|     git commit -m "chore(release): release $CRATE v$VERSION" |  | ||||||
| 
 |  | ||||||
|     awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG" |  | ||||||
| 
 |  | ||||||
|     echo "🚀 Run 'git push origin $TAG' to publish" |  | ||||||
|  |  | ||||||
							
								
								
									
										298
									
								
								README.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,285 +1,31 @@ | ||||||
| #  | # RustyPipe | ||||||
| 
 | 
 | ||||||
| [](https://crates.io/crates/rustypipe) | Client for the public YouTube / YouTube Music API (Innertube), | ||||||
| [](https://opensource.org/licenses/GPL-3.0) | inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor). | ||||||
| [](https://docs.rs/rustypipe) |  | ||||||
| [](https://codeberg.org/ThetaDev/rustypipe/actions/?workflow=ci.yaml) |  | ||||||
| 
 |  | ||||||
| RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music API |  | ||||||
| (Innertube), inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor). |  | ||||||
| 
 | 
 | ||||||
| ## Features | ## Features | ||||||
| 
 | 
 | ||||||
| ### YouTube | ### YouTube | ||||||
| 
 | 
 | ||||||
| - **Player** (video/audio streams, subtitles) | - [X] **Player** (video/audio streams, subtitles) | ||||||
| - **VideoDetails** (metadata, comments, recommended videos) | - [X] **Playlist** | ||||||
| - **Playlist** | - [X] **VideoDetails** (metadata, comments, recommended videos) | ||||||
| - **Channel** (videos, shorts, livestreams, playlists, info, search) | - [X] **Channel** (videos, shorts, livestreams, playlists, info, search) | ||||||
| - **ChannelRSS** | - [X] **ChannelRSS** | ||||||
| - **Search** (with filters) | - [X] **Search** (with filters) | ||||||
| - **Search suggestions** | - [X] **Search suggestions** | ||||||
| - **Trending** | - [X] **Trending** | ||||||
| - **URL resolver** | - [X] **URL resolver** | ||||||
| - **Subscriptions** |  | ||||||
| - **Playback history** |  | ||||||
| 
 | 
 | ||||||
| ### YouTube Music | ### YouTube Music | ||||||
| 
 | 
 | ||||||
| - **Playlist** | - [X] **Playlist** | ||||||
| - **Album** | - [X] **Album** | ||||||
| - **Artist** | - [X] **Artist** | ||||||
| - **Search** | - [X] **Search** | ||||||
| - **Search suggestions** | - [X] **Search suggestions** | ||||||
| - **Radio** | - [X] **Radio** | ||||||
| - **Track details** (lyrics, recommendations) | - [X] **Track details** (lyrics, recommendations) | ||||||
| - **Moods/Genres** | - [X] **Moods/Genres** | ||||||
| - **Charts** | - [X] **Charts** | ||||||
| - **New** (albums, music videos) | - [X] **New** | ||||||
| - **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" |  | ||||||
| tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ### Watch a video |  | ||||||
| 
 |  | ||||||
| ```rust ignore |  | ||||||
| use std::process::Command; |  | ||||||
| 
 |  | ||||||
| use rustypipe::{client::RustyPipe, param::StreamFilter}; |  | ||||||
| 
 |  | ||||||
| #[tokio::main] |  | ||||||
| async fn main() { |  | ||||||
|     // Create a client |  | ||||||
|     let rp = RustyPipe::new(); |  | ||||||
|     // Fetch the player |  | ||||||
|     let player = rp.query().player("pPvd8UxmSbQ").await.unwrap(); |  | ||||||
|     // Select the best streams |  | ||||||
|     let (video, audio) = player.select_video_audio_stream(&StreamFilter::default()); |  | ||||||
| 
 |  | ||||||
|     // Open mpv player |  | ||||||
|     let mut args = vec![video.expect("no video stream").url.to_owned()]; |  | ||||||
|     if let Some(audio) = audio { |  | ||||||
|         args.push(format!("--audio-file={}", audio.url)); |  | ||||||
|     } |  | ||||||
|     Command::new("mpv").args(args).output().unwrap(); |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ### Get a playlist |  | ||||||
| 
 |  | ||||||
| ```rust ignore |  | ||||||
| use rustypipe::client::RustyPipe |  | ||||||
| 
 |  | ||||||
| #[tokio::main] |  | ||||||
| async fn main() { |  | ||||||
|     // Create a client |  | ||||||
|     let rp = RustyPipe::new(); |  | ||||||
|     // Get the playlist |  | ||||||
|     let playlist = rp |  | ||||||
|         .query() |  | ||||||
|         .playlist("PL2_OBreMn7FrsiSW0VDZjdq0xqUKkZYHT") |  | ||||||
|         .await |  | ||||||
|         .unwrap(); |  | ||||||
|     // Get all items (maximum: 1000) |  | ||||||
|     playlist.videos.extend_limit(rp.query(), 1000).await.unwrap(); |  | ||||||
| 
 |  | ||||||
|     println!("Name: {}", playlist.name); |  | ||||||
|     println!("Author: {}", playlist.channel.unwrap().name); |  | ||||||
|     println!("Last update: {}", playlist.last_update.unwrap()); |  | ||||||
| 
 |  | ||||||
|     playlist |  | ||||||
|         .videos |  | ||||||
|         .items |  | ||||||
|         .iter() |  | ||||||
|         .for_each(|v| println!("[{}] {} ({}s)", v.id, v.name, v.length)); |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| **Output:** |  | ||||||
| 
 |  | ||||||
| ```txt |  | ||||||
| Name: Homelab |  | ||||||
| Author: Jeff Geerling |  | ||||||
| Last update: 2023-05-04 |  | ||||||
| [cVWF3u-y-Zg] I put a computer in my computer (720s) |  | ||||||
| [ecdm3oA-QdQ] 6-in-1: Build a 6-node Ceph cluster on this Mini ITX Motherboard (783s) |  | ||||||
| [xvE4HNJZeIg] Scrapyard Server: Fastest all-SSD NAS! (733s) |  | ||||||
| [RvnG-ywF6_s] Nanosecond clock sync with a Raspberry Pi (836s) |  | ||||||
| [R2S2RMNv7OU] I made the Petabyte Raspberry Pi even faster! (572s) |  | ||||||
| [FG--PtrDmw4] Hiding Macs in my Rack! (515s) |  | ||||||
| ... |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ### Get a channel |  | ||||||
| 
 |  | ||||||
| ```rust ignore |  | ||||||
| use rustypipe::client::RustyPipe |  | ||||||
| 
 |  | ||||||
| #[tokio::main] |  | ||||||
| async fn main() { |  | ||||||
|     // Create a client |  | ||||||
|     let rp = RustyPipe::new(); |  | ||||||
|     // Get the channel |  | ||||||
|     let channel = rp |  | ||||||
|         .query() |  | ||||||
|         .channel_videos("UCl2mFZoRqjw_ELax4Yisf6w") |  | ||||||
|         .await |  | ||||||
|         .unwrap(); |  | ||||||
| 
 |  | ||||||
|     println!("Name: {}", channel.name); |  | ||||||
|     println!("Description: {}", channel.description); |  | ||||||
|     println!("Subscribers: {}", channel.subscriber_count.unwrap()); |  | ||||||
| 
 |  | ||||||
|     channel |  | ||||||
|         .content |  | ||||||
|         .items |  | ||||||
|         .iter() |  | ||||||
|         .for_each(|v| println!("[{}] {} ({}s)", v.id, v.name, v.length.unwrap())); |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| **Output:** |  | ||||||
| 
 |  | ||||||
| ```txt |  | ||||||
| Name: Louis Rossmann |  | ||||||
| Description: I discuss random things of interest to me. (...) |  | ||||||
| Subscribers: 1780000 |  | ||||||
| [qBHgJx_rb8E] Introducing Rossmann senior, a genuine fossil 😃 (122s) |  | ||||||
| [TmV8eAtXc3s] Am I wrong about CompTIA? (592s) |  | ||||||
| [CjOJJc1qzdY] How FUTO projects loosen Google's grip on your life! (588s) |  | ||||||
| [0A10JtkkL9A] a private moment between a man and his kitten (522s) |  | ||||||
| [zbHq5_1Cd5U] Is Texas mandating auto repair shops use OEM parts? SB1083 analysis & breakdown; tldr, no. (645s) |  | ||||||
| [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`. |  | ||||||
|  |  | ||||||
							
								
								
									
										207
									
								
								cli/CHANGELOG.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,207 +0,0 @@ | ||||||
| # Changelog |  | ||||||
| 
 |  | ||||||
| All notable changes to this project will be documented in this file. |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.7.2](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.7.1..rustypipe-cli/v0.7.2) - 2025-03-16 |  | ||||||
| 
 |  | ||||||
| ### ⚙️ Miscellaneous Tasks |  | ||||||
| 
 |  | ||||||
| - *(deps)* Update rustypipe to 0.11.1 |  | ||||||
| - *(deps)* Update rustypipe-downloader to 0.3.1 |  | ||||||
| - *(deps)* Update rust crate rstest to 0.25.0 - ([9ed1306](https://codeberg.org/ThetaDev/rustypipe/commit/9ed1306f3aaeb993c409997ddfbc47499e4f4d22)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.7.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-cli/v0.7.0..rustypipe-cli/v0.7.1) - 2025-02-26 |  | ||||||
| 
 |  | ||||||
| ### ⚙️ Miscellaneous Tasks |  | ||||||
| 
 |  | ||||||
| - *(deps)* Update rustypipe to 0.11.0 - ([035c07f](https://codeberg.org/ThetaDev/rustypipe/commit/035c07f170aa293bcc626f27998c2b2b28660881)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [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 --> |  | ||||||
							
								
								
									
										1524
									
								
								cli/Cargo.lock
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -1,70 +1,18 @@ | ||||||
| [package] | [package] | ||||||
| name = "rustypipe-cli" | name = "rustypipe-cli" | ||||||
| version = "0.7.2" | version = "0.1.0" | ||||||
| rust-version = "1.70.0" | edition = "2021" | ||||||
| edition.workspace = true |  | ||||||
| authors.workspace = true |  | ||||||
| license.workspace = true |  | ||||||
| repository.workspace = true |  | ||||||
| keywords.workspace = true |  | ||||||
| categories.workspace = true |  | ||||||
| description = "CLI for RustyPipe - download videos and extract data from YouTube / YouTube Music" |  | ||||||
| 
 |  | ||||||
| [features] |  | ||||||
| default = ["native-tls"] |  | ||||||
| timezone = ["dep:time", "dep:time-tz"] |  | ||||||
| 
 |  | ||||||
| # Reqwest TLS options |  | ||||||
| native-tls = [ |  | ||||||
|     "reqwest/native-tls", |  | ||||||
|     "rustypipe/native-tls", |  | ||||||
|     "rustypipe-downloader/native-tls", |  | ||||||
| ] |  | ||||||
| native-tls-alpn = [ |  | ||||||
|     "reqwest/native-tls-alpn", |  | ||||||
|     "rustypipe/native-tls-alpn", |  | ||||||
|     "rustypipe-downloader/native-tls-alpn", |  | ||||||
| ] |  | ||||||
| native-tls-vendored = [ |  | ||||||
|     "reqwest/native-tls-vendored", |  | ||||||
|     "rustypipe/native-tls-vendored", |  | ||||||
|     "rustypipe-downloader/native-tls-vendored", |  | ||||||
| ] |  | ||||||
| rustls-tls-webpki-roots = [ |  | ||||||
|     "reqwest/rustls-tls-webpki-roots", |  | ||||||
|     "rustypipe/rustls-tls-webpki-roots", |  | ||||||
|     "rustypipe-downloader/rustls-tls-webpki-roots", |  | ||||||
| ] |  | ||||||
| rustls-tls-native-roots = [ |  | ||||||
|     "reqwest/rustls-tls-native-roots", |  | ||||||
|     "rustypipe/rustls-tls-native-roots", |  | ||||||
|     "rustypipe-downloader/rustls-tls-native-roots", |  | ||||||
| ] |  | ||||||
| 
 | 
 | ||||||
| [dependencies] | [dependencies] | ||||||
| rustypipe = { workspace = true, features = ["rss", "userdata"] } | rustypipe = { path = "../" } | ||||||
| rustypipe-downloader.workspace = true | rustypipe-downloader = { path = "../downloader" } | ||||||
| reqwest.workspace = true | reqwest = { version = "0.11.11", default_features = false } | ||||||
| tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } | tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] } | ||||||
| futures-util.workspace = true | indicatif = "0.17.0" | ||||||
| serde.workspace = true | futures = "0.3.21" | ||||||
| serde_json.workspace = true | anyhow = "1.0" | ||||||
| quick-xml.workspace = true | clap = { version = "4.0.29", features = ["derive"] } | ||||||
| time = { workspace = true, optional = true } | env_logger = "0.10.0" | ||||||
| time-tz = { version = "2.0.0", optional = true } | serde = "1.0" | ||||||
| 
 | serde_json = "1.0.82" | ||||||
| indicatif.workspace = true | serde_yaml = "0.9.19" | ||||||
| anyhow.workspace = true |  | ||||||
| clap.workspace = true |  | ||||||
| tracing.workspace = true |  | ||||||
| tracing-subscriber.workspace = true |  | ||||||
| serde_yaml.workspace = true |  | ||||||
| dirs.workspace = true |  | ||||||
| 
 |  | ||||||
| anstream = "0.6.15" |  | ||||||
| owo-colors = "4.0.0" |  | ||||||
| const_format = "0.2.33" |  | ||||||
| 
 |  | ||||||
| [[bin]] |  | ||||||
| name = "rustypipe" |  | ||||||
| path = "src/main.rs" |  | ||||||
|  |  | ||||||
							
								
								
									
										174
									
								
								cli/README.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,174 +0,0 @@ | ||||||
| #  CLI |  | ||||||
| 
 |  | ||||||
| [](https://crates.io/crates/rustypipe-cli) |  | ||||||
| [](https://opensource.org/licenses/GPL-3.0) |  | ||||||
| [](https://codeberg.org/ThetaDev/rustypipe/actions/?workflow=ci.yaml) |  | ||||||
| 
 |  | ||||||
| The RustyPipe CLI is a powerful YouTube client for the command line. It allows you to |  | ||||||
| access most of the features of the RustyPipe crate: getting data from YouTube and |  | ||||||
| downloading videos. |  | ||||||
| 
 |  | ||||||
| ## Installation |  | ||||||
| 
 |  | ||||||
| You can download a compiled version of RustyPipe here: |  | ||||||
| <https://codeberg.org/ThetaDev/rustypipe/releases> |  | ||||||
| 
 |  | ||||||
| Alternatively, you can compile it yourself by installing [Rust](https://rustup.rs/) and |  | ||||||
| running `cargo install rustypipe-cli`. |  | ||||||
| 
 |  | ||||||
| To be able to access streams from web-based clients (Desktop, Mobile) you need to |  | ||||||
| download [rustypipe-botguard](https://codeberg.org/ThetaDev/rustypipe-botguard/releases) |  | ||||||
| and place the binary either in the PATH or the current working directory. |  | ||||||
| 
 |  | ||||||
| For downloading videos you also need to have ffmpeg installed. |  | ||||||
| 
 |  | ||||||
| ## `get`: Fetch information |  | ||||||
| 
 |  | ||||||
| You can call the get command with any YouTube entity ID or URL and RustyPipe will fetch |  | ||||||
| the associated metadata. It can fetch channels, playlists, albums and videos. |  | ||||||
| 
 |  | ||||||
| **Usage:** `rustypipe get UC2TXq_t06Hjdr2g_KdKpHQg` |  | ||||||
| 
 |  | ||||||
| - `-l`, `--limit` Limit the number of list items to fetch |  | ||||||
| - `-t`, `--tab` Channel tab (options: **videos**, shorts, live, playlists, info) |  | ||||||
| - `-m, --music` Use the YouTube Music API |  | ||||||
| - `--rss`Fetch the RSS feed of a channel |  | ||||||
| - `--comments` Get comments (options: top, latest) |  | ||||||
| - `--lyrics` Get the lyrics for YTM tracks |  | ||||||
| - `--player` Get the player data instead of the video details when fetching videos |  | ||||||
| - `-c`, `--client-type` YT clients used to fetch player data (options: desktop, tv, |  | ||||||
|   tv-embed, android, ios; if multiple clients are specified, they are attempted in |  | ||||||
|   order) |  | ||||||
| 
 |  | ||||||
| ## `search`: Search YouTube |  | ||||||
| 
 |  | ||||||
| With the search command you can search the entire YouTube platform or individual |  | ||||||
| channels. YouTube Music search is also supported. |  | ||||||
| 
 |  | ||||||
| Note that search filters are only supported when searching YouTube. They have no effect |  | ||||||
| when searching YTM or individual channels. |  | ||||||
| 
 |  | ||||||
| **Usage:** `rustypipe search "query"` |  | ||||||
| 
 |  | ||||||
| ### Options |  | ||||||
| 
 |  | ||||||
| - `-l`, `--limit` Limit the number of list items to fetch |  | ||||||
| 
 |  | ||||||
| - `--item-type` Filter results by item type |  | ||||||
| - `--length` Filter results by video length |  | ||||||
| - `--date` Filter results by upload date (options: hour, day, week, month, year) |  | ||||||
| - `--order` Sort search results (options: rating, date, views) |  | ||||||
| - `--channel` Channel ID for searching channel videos |  | ||||||
| - `-m`, `--music` Search YouTube Music in the given category (options: all, tracks, |  | ||||||
|   videos, artists, albums, playlists-ytm, playlists-community) |  | ||||||
| 
 |  | ||||||
| ## `dl`: Download videos |  | ||||||
| 
 |  | ||||||
| The downloader can download individual videos, playlists, albums and channels. Multiple |  | ||||||
| videos can be downloaded in parallel for improved performance. |  | ||||||
| 
 |  | ||||||
| **Usage:** `rustypipe dl eRsGyueVLvQ` |  | ||||||
| 
 |  | ||||||
| ### Options |  | ||||||
| 
 |  | ||||||
| - `-o`, `--output` Download to the given directory |  | ||||||
| - `--output-file` Download to the given file |  | ||||||
| - `--template` Download to a path determined by a template |  | ||||||
| 
 |  | ||||||
| - `-r`, `--resolution` Video resolution (e.g. 720, 1080). Set to 0 for audio-only |  | ||||||
| - `-a`, `--audio` Download only the audio track and write track metadata + album cover |  | ||||||
| - `-p`, `--parallel` Number of videos downloaded in parallel (default: 8) |  | ||||||
| - `-m`, `--music` Use YouTube Music for downloading playlists |  | ||||||
| - `-l`, `--limit` Limit the number of videos to download (default: 1000) |  | ||||||
| - `-c`, `--client-type` YT clients used to fetch player data (options: desktop, tv, |  | ||||||
|   tv-embed, android, ios; if multiple clients are specified, they are attempted in |  | ||||||
|   order) |  | ||||||
| 
 |  | ||||||
| ## `vdata`: Get visitor data |  | ||||||
| 
 |  | ||||||
| You can use the vdata command to get a new visitor data ID. This feature may come in |  | ||||||
| handy for testing and reproducing A/B tests. |  | ||||||
| 
 |  | ||||||
| ## `releases` Get YouTube Music new releases |  | ||||||
| 
 |  | ||||||
| Get a list of new albums or music videos on YouTube Music |  | ||||||
| 
 |  | ||||||
| **Usage:** `rustypipe releases` or `rustypipe releases --videos` |  | ||||||
| 
 |  | ||||||
| ## `charts`: Get YouTube Music charts |  | ||||||
| 
 |  | ||||||
| Get a list of the most popular tracks and artists for a given country |  | ||||||
| 
 |  | ||||||
| **Usage:** `rustypipe charts DE` |  | ||||||
| 
 |  | ||||||
| ## `history`: Get YouTube playback history |  | ||||||
| 
 |  | ||||||
| Get a list of recently played videos or tracks |  | ||||||
| 
 |  | ||||||
| ### Options |  | ||||||
| 
 |  | ||||||
| - `-l`, `--limit` Limit the number of list items to fetch |  | ||||||
| - `--search` Search the playback history (unavailable on YouTube Music) |  | ||||||
| - `-m`, `--music` Get the YouTube Music playback history |  | ||||||
| 
 |  | ||||||
| ## `subscriptions`: Get subscribed channels |  | ||||||
| 
 |  | ||||||
| You can use the RustyPipe CLI to get a list of the channels you subscribed to. With the |  | ||||||
| `--format` flag you can export then in different formats, including OPML and NewPipe |  | ||||||
| JSON. |  | ||||||
| 
 |  | ||||||
| With the `--feed` option you can output a list of the latest videos from your |  | ||||||
| subscription feed instead. |  | ||||||
| 
 |  | ||||||
| ### Options |  | ||||||
| 
 |  | ||||||
| - `-l`, `--limit` Limit the number of list items to fetch |  | ||||||
| - `-m`, `--music` Get a list of subscribed YouTube Music artists |  | ||||||
| - `--feed` Output YouTube Music subscription feed |  | ||||||
| 
 |  | ||||||
| ## `playlists`, `albums`, `tracks`: Get your YouTube library |  | ||||||
| 
 |  | ||||||
| Fetch a list of all the items saved in your YouTube/YouTube Music profile. |  | ||||||
| 
 |  | ||||||
| ### Options |  | ||||||
| 
 |  | ||||||
| - `-l`, `--limit` Limit the number of list items to fetch |  | ||||||
| - `-m`, `--music` (only for playlists): Get your YouTube Music playlists |  | ||||||
| 
 |  | ||||||
| ## Global options |  | ||||||
| 
 |  | ||||||
| - **Proxy:** RustyPipe respects the environment variables `HTTP_PROXY`, `HTTPS_PROXY` |  | ||||||
|   and `ALL_PROXY` |  | ||||||
| - **Logging:** Enable debug logging with the `-v` (verbose) flag. If you want more |  | ||||||
|   fine-grained control, use the `RUST_LOG` environment variable. |  | ||||||
| - **Visitor data:** A custom visitor data ID can be used with the `--vdata` flag |  | ||||||
| - **Authentication:** Use the commands `rustypipe login` and `rustypipe login --cookie` |  | ||||||
|   to log into your Google account using either OAuth or YouTube cookies. With the |  | ||||||
|   `--auth` flag you can use authentication for any request. |  | ||||||
| - `--lang` Change the YouTube content language |  | ||||||
| - `--country` Change the YouTube content country |  | ||||||
| - `--tz` Use a specific |  | ||||||
|   [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g. |  | ||||||
|   Europe/Berlin, Australia/Sydney) |  | ||||||
| 
 |  | ||||||
|   **Note:** this requires building rustypipe-cli with the `timezone` feature |  | ||||||
| 
 |  | ||||||
| - `--local-tz` Use the local timezone instead of UTC |  | ||||||
| - `--report` Generate a report on every request and store it in a `rustypipe_reports` |  | ||||||
|   folder in the current directory |  | ||||||
| - `--cache-file` Change the RustyPipe cache file location (Default: |  | ||||||
|   `~/.local/share/rustypipe/rustypipe_cache.json`) |  | ||||||
| - `--report-dir` Change the RustyPipe report directory location (Default: |  | ||||||
|   `~/.local/share/rustypipe/rustypipe_reports`) |  | ||||||
| - `--botguard-bin` Use a |  | ||||||
|   [rustypipe-botguard](https://codeberg.org/ThetaDev/rustypipe-botguard) binary from the |  | ||||||
|   given path for generating PO tokens |  | ||||||
| - `--no-botguard` Disable Botguard, only download videos using clients that dont require |  | ||||||
|   it |  | ||||||
| - `--pot-cache` Enable caching for session-bound PO tokens |  | ||||||
| 
 |  | ||||||
| ### Output format |  | ||||||
| 
 |  | ||||||
| By default, the CLI outputs YouTube data in a human-readable text format. If you want to |  | ||||||
| store the data or process it with a script, you should choose a machine readable output |  | ||||||
| format. You can choose both JSON and YAML with the `-f, --format` flag. |  | ||||||
							
								
								
									
										1794
									
								
								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,23 @@ | ||||||
| [package] | [package] | ||||||
| name = "rustypipe-codegen" | name = "rustypipe-codegen" | ||||||
| version = "0.1.0" | version = "0.1.0" | ||||||
| rust-version = "1.74.0" | edition = "2021" | ||||||
| edition.workspace = true |  | ||||||
| authors.workspace = true |  | ||||||
| license.workspace = true |  | ||||||
| repository.workspace = true |  | ||||||
| publish = false |  | ||||||
| 
 | 
 | ||||||
| [dependencies] | [dependencies] | ||||||
| rustypipe = { path = "../", features = ["userdata"] } | rustypipe = { path = "../" } | ||||||
| reqwest.workspace = true | reqwest = "0.11.11" | ||||||
| tokio = { workspace = true, features = ["rt-multi-thread"] } | tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] } | ||||||
| futures-util.workspace = true | futures = "0.3.21" | ||||||
| serde.workspace = true | serde = { version = "1.0", features = ["derive"] } | ||||||
| serde_json.workspace = true | serde_json = "1.0.82" | ||||||
| serde_plain.workspace = true | serde_with = "2.0.0" | ||||||
| serde_with.workspace = true | anyhow = "1.0" | ||||||
| once_cell.workspace = true | log = "0.4.17" | ||||||
| regex.workspace = true | env_logger = "0.10.0" | ||||||
| path_macro.workspace = true | clap = { version = "4.0.29", features = ["derive"] } | ||||||
| anyhow.workspace = true | phf_codegen = "0.11.1" | ||||||
| tracing.workspace = true | once_cell = "1.12.0" | ||||||
| tracing-subscriber.workspace = true | regex = "1.7.1" | ||||||
| clap.workspace = true | indicatif = "0.17.0" | ||||||
| phf_codegen.workspace = true | num_enum = "0.5.7" | ||||||
| indicatif.workspace = true | path_macro = "1.0.0" | ||||||
| 
 |  | ||||||
| num_enum = "0.7.2" |  | ||||||
| intl_pluralrules = "7.0.2" |  | ||||||
| unic-langid = "0.9.1" |  | ||||||
| ordered_hash_map = { version = "0.4.0", features = ["serde"] } |  | ||||||
|  |  | ||||||
|  | @ -1,20 +1,12 @@ | ||||||
| use std::collections::BTreeMap; |  | ||||||
| 
 |  | ||||||
| use anyhow::{bail, Result}; | use anyhow::{bail, Result}; | ||||||
| use futures_util::{stream, StreamExt}; | use futures::{stream, StreamExt}; | ||||||
| use indicatif::{ProgressBar, ProgressStyle}; | use indicatif::{ProgressBar, ProgressStyle}; | ||||||
| use num_enum::TryFromPrimitive; | use num_enum::TryFromPrimitive; | ||||||
| use once_cell::sync::Lazy; | use rustypipe::client::{ClientType, RustyPipe, YTContext}; | ||||||
| use regex::Regex; | use rustypipe::model::YouTubeItem; | ||||||
| use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery}; |  | ||||||
| use rustypipe::model::{MusicItem, YouTubeItem}; |  | ||||||
| use rustypipe::param::search_filter::{ItemType, SearchFilter}; | use rustypipe::param::search_filter::{ItemType, SearchFilter}; | ||||||
| use rustypipe::param::ChannelVideoTab; |  | ||||||
| use serde::de::IgnoredAny; |  | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
| use crate::model::QCont; |  | ||||||
| 
 |  | ||||||
| #[derive(
 | #[derive(
 | ||||||
|     Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, TryFromPrimitive, Serialize, Deserialize, |     Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, TryFromPrimitive, Serialize, Deserialize, | ||||||
| )] | )] | ||||||
|  | @ -24,32 +16,9 @@ pub enum ABTest { | ||||||
|     ThreeTabChannelLayout = 2, |     ThreeTabChannelLayout = 2, | ||||||
|     ChannelHandlesInSearchResults = 3, |     ChannelHandlesInSearchResults = 3, | ||||||
|     TrendsVideoTab = 4, |     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, |  | ||||||
|     AlbumRecommends = 21, |  | ||||||
|     CommandExecutorCommand = 22, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// List of active A/B tests that are run when none is manually specified
 | const TESTS_TO_RUN: [ABTest; 1] = [ABTest::TrendsVideoTab]; | ||||||
| const TESTS_TO_RUN: &[ABTest] = &[ |  | ||||||
|     ABTest::MusicAlbumGroupsReordered, |  | ||||||
|     ABTest::AlbumRecommends, |  | ||||||
|     ABTest::CommandExecutorCommand, |  | ||||||
| ]; |  | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize, Deserialize)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| pub struct ABTestRes { | pub struct ABTestRes { | ||||||
|  | @ -63,6 +32,7 @@ pub struct ABTestRes { | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| struct QVideo<'a> { | struct QVideo<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     video_id: &'a str, |     video_id: &'a str, | ||||||
|     content_check_ok: bool, |     content_check_ok: bool, | ||||||
|     racy_check_ok: bool, |     racy_check_ok: bool, | ||||||
|  | @ -71,6 +41,7 @@ struct QVideo<'a> { | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct QBrowse<'a> { | struct QBrowse<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     browse_id: &'a str, |     browse_id: &'a str, | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     params: Option<&'a str>, |     params: Option<&'a str>, | ||||||
|  | @ -85,6 +56,7 @@ pub async fn run_test( | ||||||
| 
 | 
 | ||||||
|     let rp = RustyPipe::new(); |     let rp = RustyPipe::new(); | ||||||
|     let pb = ProgressBar::new(n as u64); |     let pb = ProgressBar::new(n as u64); | ||||||
|  |     let http = reqwest::Client::default(); | ||||||
|     pb.set_style( |     pb.set_style( | ||||||
|         ProgressStyle::with_template( |         ProgressStyle::with_template( | ||||||
|             "{msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}", |             "{msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}", | ||||||
|  | @ -96,36 +68,20 @@ pub async fn run_test( | ||||||
|         .map(|_| { |         .map(|_| { | ||||||
|             let rp = rp.clone(); |             let rp = rp.clone(); | ||||||
|             let pb = pb.clone(); |             let pb = pb.clone(); | ||||||
|  |             let http = http.clone(); | ||||||
|             async move { |             async move { | ||||||
|                 let visitor_data = rp.query().get_visitor_data(true).await.unwrap(); |                 let visitor_data = get_visitor_data(&http).await; | ||||||
|                 let query = rp.query().visitor_data(&visitor_data); |  | ||||||
|                 let is_present = match ab { |                 let is_present = match ab { | ||||||
|                     ABTest::AttributedTextDescription => attributed_text_description(&query).await, |                     ABTest::AttributedTextDescription => { | ||||||
|                     ABTest::ThreeTabChannelLayout => three_tab_channel_layout(&query).await, |                         attributed_text_description(&rp, &visitor_data).await | ||||||
|  |                     } | ||||||
|  |                     ABTest::ThreeTabChannelLayout => { | ||||||
|  |                         three_tab_channel_layout(&rp, &visitor_data).await | ||||||
|  |                     } | ||||||
|                     ABTest::ChannelHandlesInSearchResults => { |                     ABTest::ChannelHandlesInSearchResults => { | ||||||
|                         channel_handles_in_search_results(&query).await |                         channel_handles_in_search_results(&rp, &visitor_data).await | ||||||
|                     } |                     } | ||||||
|                     ABTest::TrendsVideoTab => trends_video_tab(&query).await, |                     ABTest::TrendsVideoTab => trends_video_tab(&rp, &visitor_data).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 |  | ||||||
|                     } |  | ||||||
|                     ABTest::AlbumRecommends => album_recommends(&query).await, |  | ||||||
|                     ABTest::CommandExecutorCommand => command_executor_command(&query).await, |  | ||||||
|                 } |                 } | ||||||
|                 .unwrap(); |                 .unwrap(); | ||||||
|                 pb.inc(1); |                 pb.inc(1); | ||||||
|  | @ -139,22 +95,38 @@ pub async fn run_test( | ||||||
|     let count = results.iter().filter(|(p, _)| *p).count(); |     let count = results.iter().filter(|(p, _)| *p).count(); | ||||||
|     let vd_present = results |     let vd_present = results | ||||||
|         .iter() |         .iter() | ||||||
|         .find_map(|(p, vd)| if *p { Some(vd.clone()) } else { None }); |         .find_map(|(p, vd)| if *p { Some(vd.to_owned()) } else { None }); | ||||||
|     let vd_absent = results |     let vd_absent = results | ||||||
|         .iter() |         .iter() | ||||||
|         .find_map(|(p, vd)| if *p { None } else { Some(vd.clone()) }); |         .find_map(|(p, vd)| if !*p { Some(vd.to_owned()) } else { None }); | ||||||
| 
 | 
 | ||||||
|     (count, vd_present, vd_absent) |     (count, vd_present, vd_absent) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | async fn get_visitor_data(http: &reqwest::Client) -> String { | ||||||
|  |     let resp = http.get("https://www.youtube.com").send().await.unwrap(); | ||||||
|  |     resp.headers() | ||||||
|  |         .get_all(reqwest::header::SET_COOKIE) | ||||||
|  |         .iter() | ||||||
|  |         .find_map(|c| { | ||||||
|  |             if let Ok(cookie) = c.to_str() { | ||||||
|  |                 if let Some(after) = cookie.strip_prefix("__Secure-YEC=") { | ||||||
|  |                     return after.split_once(';').map(|s| s.0.to_owned()); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             None | ||||||
|  |         }) | ||||||
|  |         .unwrap() | ||||||
|  | } | ||||||
|  | 
 | ||||||
| pub async fn run_all_tests(n: usize, concurrency: usize) -> Vec<ABTestRes> { | pub async fn run_all_tests(n: usize, concurrency: usize) -> Vec<ABTestRes> { | ||||||
|     let mut results = Vec::new(); |     let mut results = Vec::new(); | ||||||
| 
 | 
 | ||||||
|     for ab in TESTS_TO_RUN { |     for ab in TESTS_TO_RUN { | ||||||
|         let (occurrences, vd_present, vd_absent) = run_test(*ab, n, concurrency).await; |         let (occurrences, vd_present, vd_absent) = run_test(ab, n, concurrency).await; | ||||||
|         results.push(ABTestRes { |         results.push(ABTestRes { | ||||||
|             id: *ab as u16, |             id: ab as u16, | ||||||
|             name: *ab, |             name: ab, | ||||||
|             tests: n, |             tests: n, | ||||||
|             occurrences, |             occurrences, | ||||||
|             vd_present, |             vd_present, | ||||||
|  | @ -164,13 +136,18 @@ pub async fn run_all_tests(n: usize, concurrency: usize) -> Vec<ABTestRes> { | ||||||
|     results |     results | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub async fn attributed_text_description(rp: &RustyPipeQuery) -> Result<bool> { | pub async fn attributed_text_description(rp: &RustyPipe, visitor_data: &str) -> Result<bool> { | ||||||
|  |     let query = rp.query(); | ||||||
|  |     let context = query | ||||||
|  |         .get_context(ClientType::Desktop, true, Some(visitor_data)) | ||||||
|  |         .await; | ||||||
|     let q = QVideo { |     let q = QVideo { | ||||||
|  |         context, | ||||||
|         video_id: "ZeerrnuLi5E", |         video_id: "ZeerrnuLi5E", | ||||||
|         content_check_ok: false, |         content_check_ok: false, | ||||||
|         racy_check_ok: false, |         racy_check_ok: false, | ||||||
|     }; |     }; | ||||||
|     let response_txt = rp.raw(ClientType::Desktop, "next", &q).await?; |     let response_txt = query.raw(ClientType::Desktop, "next", &q).await.unwrap(); | ||||||
| 
 | 
 | ||||||
|     if !response_txt.contains("\"Black Mamba\"") { |     if !response_txt.contains("\"Black Mamba\"") { | ||||||
|         bail!("invalid response data"); |         bail!("invalid response data"); | ||||||
|  | @ -179,13 +156,20 @@ pub async fn attributed_text_description(rp: &RustyPipeQuery) -> Result<bool> { | ||||||
|     Ok(response_txt.contains("\"attributedDescription\"")) |     Ok(response_txt.contains("\"attributedDescription\"")) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub async fn three_tab_channel_layout(rp: &RustyPipeQuery) -> Result<bool> { | pub async fn three_tab_channel_layout(rp: &RustyPipe, visitor_data: &str) -> Result<bool> { | ||||||
|     let channel = rp.channel_videos("UCR-DXc1voovS8nhAvccRZhg").await?; |     let channel = rp | ||||||
|  |         .query() | ||||||
|  |         .visitor_data(visitor_data) | ||||||
|  |         .channel_videos("UCR-DXc1voovS8nhAvccRZhg") | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|     Ok(channel.has_live || channel.has_shorts) |     Ok(channel.has_live || channel.has_shorts) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub async fn channel_handles_in_search_results(rp: &RustyPipeQuery) -> Result<bool> { | pub async fn channel_handles_in_search_results(rp: &RustyPipe, visitor_data: &str) -> Result<bool> { | ||||||
|     let search = rp |     let search = rp | ||||||
|  |         .query() | ||||||
|  |         .visitor_data(visitor_data) | ||||||
|         .search_filter("rust", &SearchFilter::new().item_type(ItemType::Channel)) |         .search_filter("rust", &SearchFilter::new().item_type(ItemType::Channel)) | ||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
|  | @ -193,18 +177,21 @@ pub async fn channel_handles_in_search_results(rp: &RustyPipeQuery) -> Result<bo | ||||||
|     Ok(search.items.items.iter().any(|itm| match itm { |     Ok(search.items.items.iter().any(|itm| match itm { | ||||||
|         YouTubeItem::Channel(channel) => channel |         YouTubeItem::Channel(channel) => channel | ||||||
|             .subscriber_count |             .subscriber_count | ||||||
|             .map(|sc| sc > 100 && channel.handle.is_some()) |             .map(|sc| sc > 100 && channel.video_count.is_none()) | ||||||
|             .unwrap_or_default(), |             .unwrap_or_default(), | ||||||
|         _ => false, |         _ => false, | ||||||
|     })) |     })) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub async fn trends_video_tab(rp: &RustyPipeQuery) -> Result<bool> { | pub async fn trends_video_tab(rp: &RustyPipe, visitor_data: &str) -> Result<bool> { | ||||||
|     let res = rp |     let query = rp.query().visitor_data(visitor_data); | ||||||
|  |     let context = query.get_context(ClientType::Desktop, true, None).await; | ||||||
|  |     let res = query | ||||||
|         .raw( |         .raw( | ||||||
|             ClientType::Desktop, |             ClientType::Desktop, | ||||||
|             "browse", |             "browse", | ||||||
|             &QBrowse { |             &QBrowse { | ||||||
|  |                 context, | ||||||
|                 browse_id: "FEtrending", |                 browse_id: "FEtrending", | ||||||
|                 params: None, |                 params: None, | ||||||
|             }, |             }, | ||||||
|  | @ -213,268 +200,3 @@ pub async fn trends_video_tab(rp: &RustyPipeQuery) -> Result<bool> { | ||||||
| 
 | 
 | ||||||
|     Ok(res.contains("\"4gIOGgxtb3N0X3BvcHVsYXI%3D\"")) |     Ok(res.contains("\"4gIOGgxtb3N0X3BvcHVsYXI%3D\"")) | ||||||
| } | } | ||||||
| 
 |  | ||||||
| pub async fn trends_page_header_renderer(rp: &RustyPipeQuery) -> Result<bool> { |  | ||||||
|     let res = rp |  | ||||||
|         .raw( |  | ||||||
|             ClientType::Desktop, |  | ||||||
|             "browse", |  | ||||||
|             &QBrowse { |  | ||||||
|                 browse_id: "FEtrending", |  | ||||||
|                 params: None, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
| 
 |  | ||||||
|     #[derive(Debug, Deserialize)] |  | ||||||
|     struct D { |  | ||||||
|         header: BTreeMap<String, IgnoredAny>, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let data = serde_json::from_str::<D>(&res)?; |  | ||||||
| 
 |  | ||||||
|     Ok(data.header.contains_key("pageHeaderRenderer")) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| 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\"")) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| pub async fn album_recommends(rp: &RustyPipeQuery) -> Result<bool> { |  | ||||||
|     let id = "MPREb_u1I69lSAe5v"; |  | ||||||
|     let res = rp |  | ||||||
|         .raw( |  | ||||||
|             ClientType::DesktopMusic, |  | ||||||
|             "browse", |  | ||||||
|             &QBrowse { |  | ||||||
|                 browse_id: id, |  | ||||||
|                 params: None, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
|     Ok(res.contains("\"musicCarouselShelfRenderer\"")) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| pub async fn command_executor_command(rp: &RustyPipeQuery) -> Result<bool> { |  | ||||||
|     let id = "VLPLbZIPy20-1pN7mqjckepWF78ndb6ci_qi"; |  | ||||||
|     let res = rp |  | ||||||
|         .raw( |  | ||||||
|             ClientType::Desktop, |  | ||||||
|             "browse", |  | ||||||
|             &QBrowse { |  | ||||||
|                 browse_id: id, |  | ||||||
|                 params: None, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
|     Ok(res.contains("\"commandExecutorCommand\"")) |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,41 +1,25 @@ | ||||||
| use std::{collections::BTreeMap, fs::File, io::BufReader}; | use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path}; | ||||||
| 
 | 
 | ||||||
| use futures_util::stream::{self, StreamExt}; | use futures::stream::{self, StreamExt}; | ||||||
| use path_macro::path; | use path_macro::path; | ||||||
| use rustypipe::{ | use rustypipe::{ | ||||||
|     client::{ClientType, RustyPipe, RustyPipeQuery}, |     client::{ClientType, RustyPipe, RustyPipeQuery, YTContext}, | ||||||
|     model::AlbumType, |     model::AlbumType, | ||||||
|     param::{Language, LANGUAGES}, |     param::{locale::LANGUAGES, Language}, | ||||||
| }; | }; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use serde_with::rust::deserialize_ignore_any; |  | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::util::{self, TextRuns}; | ||||||
|     model::{ContentsRenderer, QBrowse, SectionList, Tab, TextRuns}, |  | ||||||
|     util::{self, DICT_DIR}, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] | pub async fn collect_album_types(project_root: &Path, concurrency: usize) { | ||||||
| #[serde(rename_all = "snake_case")] |     let json_path = path!(project_root / "testfiles" / "dict" / "album_type_samples.json"); | ||||||
| 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 = [ |     let album_types = [ | ||||||
|         (AlbumTypeX::Album, "MPREb_nlBWQROfvjo"), |         (AlbumType::Album, "MPREb_nlBWQROfvjo"), | ||||||
|         (AlbumTypeX::Single, "MPREb_bHfHGoy7vuv"), |         (AlbumType::Single, "MPREb_bHfHGoy7vuv"), | ||||||
|         (AlbumTypeX::Ep, "MPREb_u1I69lSAe5v"), |         (AlbumType::Ep, "MPREb_u1I69lSAe5v"), | ||||||
|         (AlbumTypeX::Audiobook, "MPREb_gaoNzsQHedo"), |         (AlbumType::Audiobook, "MPREb_gaoNzsQHedo"), | ||||||
|         (AlbumTypeX::Show, "MPREb_cwzk8EUwypZ"), |         (AlbumType::Show, "MPREb_cwzk8EUwypZ"), | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|     let rp = RustyPipe::new(); |     let rp = RustyPipe::new(); | ||||||
|  | @ -45,7 +29,7 @@ pub async fn collect_album_types(concurrency: usize) { | ||||||
|             let rp = rp.clone(); |             let rp = rp.clone(); | ||||||
|             async move { |             async move { | ||||||
|                 let query = rp.query().lang(lang); |                 let query = rp.query().lang(lang); | ||||||
|                 let mut data: BTreeMap<AlbumTypeX, String> = BTreeMap::new(); |                 let mut data: BTreeMap<AlbumType, String> = BTreeMap::new(); | ||||||
| 
 | 
 | ||||||
|                 for (album_type, id) in album_types { |                 for (album_type, id) in album_types { | ||||||
|                     let atype_txt = get_album_type(&query, id).await; |                     let atype_txt = get_album_type(&query, id).await; | ||||||
|  | @ -53,22 +37,6 @@ pub async fn collect_album_types(concurrency: usize) { | ||||||
|                     data.insert(album_type, atype_txt); |                     data.insert(album_type, atype_txt); | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 let (albums_txt, singles_txt) = get_album_groups(&query).await; |  | ||||||
|                 println!( |  | ||||||
|                     "collected {}-{:?} ({})", |  | ||||||
|                     lang, |  | ||||||
|                     AlbumTypeX::AlbumRow, |  | ||||||
|                     &albums_txt |  | ||||||
|                 ); |  | ||||||
|                 println!( |  | ||||||
|                     "collected {}-{:?} ({})", |  | ||||||
|                     lang, |  | ||||||
|                     AlbumTypeX::SingleRow, |  | ||||||
|                     &singles_txt |  | ||||||
|                 ); |  | ||||||
|                 data.insert(AlbumTypeX::AlbumRow, albums_txt); |  | ||||||
|                 data.insert(AlbumTypeX::SingleRow, singles_txt); |  | ||||||
| 
 |  | ||||||
|                 (lang, data) |                 (lang, data) | ||||||
|             } |             } | ||||||
|         }) |         }) | ||||||
|  | @ -80,14 +48,14 @@ pub async fn collect_album_types(concurrency: usize) { | ||||||
|     serde_json::to_writer_pretty(file, &collected_album_types).unwrap(); |     serde_json::to_writer_pretty(file, &collected_album_types).unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub fn write_samples_to_dict() { | pub fn write_samples_to_dict(project_root: &Path) { | ||||||
|     let json_path = path!(*DICT_DIR / "album_type_samples.json"); |     let json_path = path!(project_root / "testfiles" / "dict" / "album_type_samples.json"); | ||||||
| 
 | 
 | ||||||
|     let json_file = File::open(json_path).unwrap(); |     let json_file = File::open(json_path).unwrap(); | ||||||
|     let collected: BTreeMap<Language, BTreeMap<String, String>> = |     let collected: BTreeMap<Language, BTreeMap<AlbumType, String>> = | ||||||
|         serde_json::from_reader(BufReader::new(json_file)).unwrap(); |         serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|     let mut dict = util::read_dict(); |     let mut dict = util::read_dict(project_root); | ||||||
|     let langs = dict.keys().copied().collect::<Vec<_>>(); |     let langs = dict.keys().map(|k| k.to_owned()).collect::<Vec<_>>(); | ||||||
| 
 | 
 | ||||||
|     for lang in langs { |     for lang in langs { | ||||||
|         let dict_entry = dict.entry(lang).or_default(); |         let dict_entry = dict.entry(lang).or_default(); | ||||||
|  | @ -95,35 +63,27 @@ pub fn write_samples_to_dict() { | ||||||
|         let mut e_langs = dict_entry.equivalent.clone(); |         let mut e_langs = dict_entry.equivalent.clone(); | ||||||
|         e_langs.push(lang); |         e_langs.push(lang); | ||||||
| 
 | 
 | ||||||
|         for lang in &e_langs { |         e_langs.iter().for_each(|lang| { | ||||||
|             collected.get(lang).unwrap().iter().for_each(|(t_str, v)| { |             collected.get(lang).unwrap().iter().for_each(|(t, v)| { | ||||||
|                 let t = |  | ||||||
|                     serde_plain::from_str::<AlbumType>(t_str.split('_').next().unwrap()).unwrap(); |  | ||||||
|                 dict_entry |                 dict_entry | ||||||
|                     .album_types |                     .album_types | ||||||
|                     .insert(v.to_lowercase().trim().to_owned(), t); |                     .insert(v.to_lowercase().trim().to_owned(), *t); | ||||||
|  |             }); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     util::write_dict(dict); |     util::write_dict(project_root, &dict); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| struct AlbumData { | struct AlbumData { | ||||||
|     contents: AlbumContents, |     header: Header, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct AlbumContents { | struct Header { | ||||||
|     two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<AlbumHeader>>>, |     music_detail_header_renderer: HeaderRenderer, | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AlbumHeader { |  | ||||||
|     music_responsive_header_renderer: HeaderRenderer, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -131,10 +91,20 @@ struct HeaderRenderer { | ||||||
|     subtitle: TextRuns, |     subtitle: TextRuns, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct QBrowse<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|  |     browse_id: &'a str, | ||||||
|  | } | ||||||
|  | 
 | ||||||
| async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String { | async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String { | ||||||
|  |     let context = query | ||||||
|  |         .get_context(ClientType::DesktopMusic, true, None) | ||||||
|  |         .await; | ||||||
|     let body = QBrowse { |     let body = QBrowse { | ||||||
|  |         context, | ||||||
|         browse_id: id, |         browse_id: id, | ||||||
|         params: None, |  | ||||||
|     }; |     }; | ||||||
|     let response_txt = query |     let response_txt = query | ||||||
|         .raw(ClientType::DesktopMusic, "browse", &body) |         .raw(ClientType::DesktopMusic, "browse", &body) | ||||||
|  | @ -143,20 +113,8 @@ async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String { | ||||||
|     let album = serde_json::from_str::<AlbumData>(&response_txt).unwrap(); |     let album = serde_json::from_str::<AlbumData>(&response_txt).unwrap(); | ||||||
| 
 | 
 | ||||||
|     album |     album | ||||||
|         .contents |         .header | ||||||
|         .two_column_browse_results_renderer |         .music_detail_header_renderer | ||||||
|         .contents |  | ||||||
|         .into_iter() |  | ||||||
|         .next() |  | ||||||
|         .unwrap() |  | ||||||
|         .tab_renderer |  | ||||||
|         .content |  | ||||||
|         .section_list_renderer |  | ||||||
|         .contents |  | ||||||
|         .into_iter() |  | ||||||
|         .next() |  | ||||||
|         .unwrap() |  | ||||||
|         .music_responsive_header_renderer |  | ||||||
|         .subtitle |         .subtitle | ||||||
|         .runs |         .runs | ||||||
|         .into_iter() |         .into_iter() | ||||||
|  | @ -164,84 +122,3 @@ async fn get_album_type(query: &RustyPipeQuery, id: &str) -> String { | ||||||
|         .unwrap() |         .unwrap() | ||||||
|         .text |         .text | ||||||
| } | } | ||||||
| 
 |  | ||||||
| async fn get_album_groups(query: &RustyPipeQuery) -> (String, String) { |  | ||||||
|     let body = QBrowse { |  | ||||||
|         browse_id: "UCOR4_bSVIXPsGa4BbCSt60Q", |  | ||||||
|         params: None, |  | ||||||
|     }; |  | ||||||
|     let response_txt = query |  | ||||||
|         .clone() |  | ||||||
|         .visitor_data("CgtwbzJZcS1XZWc1QSjM2JG8BjIKCgJERRIEEgAgCw%3D%3D") |  | ||||||
|         .raw(ClientType::DesktopMusic, "browse", &body) |  | ||||||
|         .await |  | ||||||
|         .unwrap(); |  | ||||||
|     let artist = serde_json::from_str::<ArtistData>(&response_txt).unwrap(); |  | ||||||
| 
 |  | ||||||
|     let sections = artist |  | ||||||
|         .contents |  | ||||||
|         .single_column_browse_results_renderer |  | ||||||
|         .contents |  | ||||||
|         .into_iter() |  | ||||||
|         .next() |  | ||||||
|         .map(|c| c.tab_renderer.content.section_list_renderer.contents) |  | ||||||
|         .unwrap(); |  | ||||||
|     let titles = sections |  | ||||||
|         .into_iter() |  | ||||||
|         .filter_map(|s| { |  | ||||||
|             if let ItemSection::MusicCarouselShelfRenderer(r) = s { |  | ||||||
|                 r.header |  | ||||||
|             } else { |  | ||||||
|                 None |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .map(|h| { |  | ||||||
|             h.music_carousel_shelf_basic_header_renderer |  | ||||||
|                 .title |  | ||||||
|                 .runs |  | ||||||
|                 .into_iter() |  | ||||||
|                 .next() |  | ||||||
|                 .unwrap() |  | ||||||
|                 .text |  | ||||||
|         }) |  | ||||||
|         .collect::<Vec<_>>(); |  | ||||||
|     assert!(titles.len() >= 2, "too few sections"); |  | ||||||
| 
 |  | ||||||
|     let mut titles_it = titles.into_iter(); |  | ||||||
|     (titles_it.next().unwrap(), titles_it.next().unwrap()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| struct ArtistData { |  | ||||||
|     contents: ArtistDataContents, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct ArtistDataContents { |  | ||||||
|     single_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<ItemSection>>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| enum ItemSection { |  | ||||||
|     MusicCarouselShelfRenderer(MusicCarouselShelf), |  | ||||||
|     #[serde(other, deserialize_with = "deserialize_ignore_any")] |  | ||||||
|     None, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| struct MusicCarouselShelf { |  | ||||||
|     header: Option<MusicCarouselShelfHeader>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct MusicCarouselShelfHeader { |  | ||||||
|     music_carousel_shelf_basic_header_renderer: MusicCarouselShelfHeaderRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| struct MusicCarouselShelfHeaderRenderer { |  | ||||||
|     title: TextRuns, |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,130 +0,0 @@ | ||||||
| use std::{collections::BTreeMap, fs::File, io::BufReader}; |  | ||||||
| 
 |  | ||||||
| use path_macro::path; |  | ||||||
| use rustypipe::{ |  | ||||||
|     client::{ClientType, RustyPipe}, |  | ||||||
|     param::{Language, LANGUAGES}, |  | ||||||
| }; |  | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_with::rust::deserialize_ignore_any; |  | ||||||
| 
 |  | ||||||
| use crate::{ |  | ||||||
|     model::{QBrowse, SectionList, TextRuns}, |  | ||||||
|     util::{self, DICT_DIR}, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| pub async fn collect_album_versions_titles() { |  | ||||||
|     let json_path = path!(*DICT_DIR / "other_versions_titles.json"); |  | ||||||
|     let mut res = BTreeMap::new(); |  | ||||||
| 
 |  | ||||||
|     let rp = RustyPipe::new(); |  | ||||||
| 
 |  | ||||||
|     for lang in LANGUAGES { |  | ||||||
|         let query = QBrowse { |  | ||||||
|             browse_id: "MPREb_nlBWQROfvjo", |  | ||||||
|             params: None, |  | ||||||
|         }; |  | ||||||
|         let raw_resp = rp |  | ||||||
|             .query() |  | ||||||
|             .lang(lang) |  | ||||||
|             .raw(ClientType::DesktopMusic, "browse", &query) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
|         let data = serde_json::from_str::<AlbumData>(&raw_resp).unwrap(); |  | ||||||
|         let title = data |  | ||||||
|             .contents |  | ||||||
|             .two_column_browse_results_renderer |  | ||||||
|             .secondary_contents |  | ||||||
|             .section_list_renderer |  | ||||||
|             .contents |  | ||||||
|             .into_iter() |  | ||||||
|             .find_map(|x| match x { |  | ||||||
|                 ItemSection::MusicCarouselShelfRenderer(music_carousel_shelf) => { |  | ||||||
|                     Some(music_carousel_shelf) |  | ||||||
|                 } |  | ||||||
|                 ItemSection::None => None, |  | ||||||
|             }) |  | ||||||
|             .expect("other versions") |  | ||||||
|             .header |  | ||||||
|             .expect("header") |  | ||||||
|             .music_carousel_shelf_basic_header_renderer |  | ||||||
|             .title |  | ||||||
|             .runs |  | ||||||
|             .into_iter() |  | ||||||
|             .next() |  | ||||||
|             .unwrap() |  | ||||||
|             .text; |  | ||||||
|         println!("{lang}: {title}"); |  | ||||||
|         res.insert(lang, title); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     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 / "other_versions_titles.json"); |  | ||||||
|     let json_file = File::open(json_path).unwrap(); |  | ||||||
|     let collected: BTreeMap<Language, String> = |  | ||||||
|         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(); |  | ||||||
|         assert_eq!(e, e.trim()); |  | ||||||
|         dict_entry.album_versions_title = e.to_owned(); |  | ||||||
| 
 |  | ||||||
|         for lang in &dict_entry.equivalent { |  | ||||||
|             let ee = collected.get(lang).unwrap(); |  | ||||||
|             if ee != e { |  | ||||||
|                 panic!("equivalent lang conflict, lang: {lang}"); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     util::write_dict(dict); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| struct AlbumData { |  | ||||||
|     contents: AlbumDataContents, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct AlbumDataContents { |  | ||||||
|     two_column_browse_results_renderer: X1, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct X1 { |  | ||||||
|     secondary_contents: 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); |  | ||||||
| } |  | ||||||
							
								
								
									
										93
									
								
								codegen/src/collect_datetimes.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,93 @@ | ||||||
|  | use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path}; | ||||||
|  | 
 | ||||||
|  | use futures::{stream, StreamExt}; | ||||||
|  | use path_macro::path; | ||||||
|  | use rustypipe::{ | ||||||
|  |     client::RustyPipe, | ||||||
|  |     param::{locale::LANGUAGES, Language}, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | use crate::util; | ||||||
|  | 
 | ||||||
|  | type CollectedDates = BTreeMap<Language, String>; | ||||||
|  | 
 | ||||||
|  | const FILENAME: &str = "datetime_samples.json"; | ||||||
|  | 
 | ||||||
|  | // A channel with an upcoming video or livestream
 | ||||||
|  | const CHANNEL_ID: &str = "UCWxlUwW9BgGISaakjGM37aw"; | ||||||
|  | const VIDEO_ID: &str = "p9FfS9l2NVA"; | ||||||
|  | 
 | ||||||
|  | const YEAR: u64 = 2023; | ||||||
|  | const YEAR_SHORT: u64 = 23; | ||||||
|  | const MONTH: u64 = 4; | ||||||
|  | const DAY: u64 = 14; | ||||||
|  | const HOUR: u64 = 15; | ||||||
|  | const HOUR_12: u64 = 3; | ||||||
|  | const MINUTE: u64 = 0; | ||||||
|  | 
 | ||||||
|  | /// Collect upcoming video dates from the TV client in every supported language
 | ||||||
|  | /// and write them to `testfiles/dict/datetime_samples.json`
 | ||||||
|  | pub async fn collect_datetimes(project_root: &Path, concurrency: usize) { | ||||||
|  |     let json_path = path!(project_root / "testfiles" / "dict" / FILENAME); | ||||||
|  | 
 | ||||||
|  |     let rp = RustyPipe::new(); | ||||||
|  |     let collected_dates: CollectedDates = stream::iter(LANGUAGES) | ||||||
|  |         .map(|lang| { | ||||||
|  |             let rp = rp.clone(); | ||||||
|  |             println!("collecting {lang}"); | ||||||
|  |             async move { | ||||||
|  |                 let channel = rp.query().lang(lang).channel_tv(CHANNEL_ID).await.unwrap(); | ||||||
|  |                 let video = channel | ||||||
|  |                     .videos | ||||||
|  |                     .into_iter() | ||||||
|  |                     .find(|v| v.id == VIDEO_ID) | ||||||
|  |                     .unwrap(); | ||||||
|  |                 ( | ||||||
|  |                     lang, | ||||||
|  |                     video | ||||||
|  |                         .publish_date_txt | ||||||
|  |                         .unwrap_or_else(|| panic!("no publish_date_txt in {}", lang)), | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |         .buffer_unordered(concurrency) | ||||||
|  |         .collect() | ||||||
|  |         .await; | ||||||
|  | 
 | ||||||
|  |     let file = File::create(json_path).unwrap(); | ||||||
|  |     serde_json::to_writer_pretty(file, &collected_dates).unwrap(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Attempt to parse the numbers collected by `collect-datetimes`
 | ||||||
|  | /// and write the results to `dictionary.json`.
 | ||||||
|  | pub fn write_samples_to_dict(project_root: &Path) { | ||||||
|  |     let json_path = path!(project_root / "testfiles" / "dict" / FILENAME); | ||||||
|  | 
 | ||||||
|  |     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(project_root); | ||||||
|  |     let langs = dict.keys().map(|k| k.to_owned()).collect::<Vec<_>>(); | ||||||
|  | 
 | ||||||
|  |     for lang in langs { | ||||||
|  |         let datestr = &collected_dates[&lang]; | ||||||
|  |         let numbers = util::parse_numeric_vec::<u64>(datestr); | ||||||
|  |         let order = numbers | ||||||
|  |             .iter() | ||||||
|  |             .map(|n| match *n { | ||||||
|  |                 YEAR => 'Y', | ||||||
|  |                 YEAR_SHORT => 'y', | ||||||
|  |                 MONTH => 'M', | ||||||
|  |                 DAY => 'D', | ||||||
|  |                 HOUR => 'H', | ||||||
|  |                 HOUR_12 => 'h', | ||||||
|  |                 MINUTE => 'm', | ||||||
|  |                 _ => panic!("unknown number {n} in {datestr} ({lang})"), | ||||||
|  |             }) | ||||||
|  |             .collect::<String>(); | ||||||
|  |         assert_eq!(order.len(), 5); | ||||||
|  |         dict.get_mut(&lang).unwrap().datetime_order = order; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     util::write_dict(project_root, &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); |  | ||||||
| } |  | ||||||
|  | @ -1,32 +1,25 @@ | ||||||
| use std::sync::Arc; | use std::collections::{HashMap, HashSet}; | ||||||
| use std::{ | use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path}; | ||||||
|     collections::{BTreeMap, HashMap, HashSet}, |  | ||||||
|     fs::File, |  | ||||||
|     io::BufReader, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| use anyhow::{Context, Result}; | use anyhow::{Context, Result}; | ||||||
| use futures_util::{stream, StreamExt}; | use futures::{stream, StreamExt}; | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use path_macro::path; | use path_macro::path; | ||||||
| use regex::Regex; | use regex::Regex; | ||||||
| use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery}; | use reqwest::{header, Client}; | ||||||
| use rustypipe::param::{Language, LANGUAGES}; | use rustypipe::param::{locale::LANGUAGES, Language}; | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
|  | use serde_with::serde_as; | ||||||
|  | use serde_with::VecSkipError; | ||||||
| 
 | 
 | ||||||
| use crate::model::{Channel, ContinuationResponse}; | use crate::util::{self, Text}; | ||||||
| use crate::util::DICT_DIR; |  | ||||||
| use crate::{ |  | ||||||
|     model::{QBrowse, QCont, TextRuns}, |  | ||||||
|     util, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| type CollectedNumbers = BTreeMap<Language, BTreeMap<String, u64>>; | type CollectedNumbers = BTreeMap<Language, BTreeMap<u8, (String, u64)>>; | ||||||
| 
 | 
 | ||||||
| /// Collect video view count texts in every supported language
 | /// Collect video view count texts in every supported language
 | ||||||
| /// and write them to `testfiles/dict/large_number_samples.json`.
 | /// and write them to `testfiles/dict/large_number_samples.json`.
 | ||||||
| ///
 | ///
 | ||||||
| /// YouTube's API outputs subscriber and view counts only in a
 | /// YouTube's API outputs the subscriber count of a channel only in a
 | ||||||
| /// approximated format (e.g *880K subscribers*), which varies
 | /// approximated format (e.g *880K subscribers*), which varies
 | ||||||
| /// by language.
 | /// by language.
 | ||||||
| ///
 | ///
 | ||||||
|  | @ -37,117 +30,99 @@ type CollectedNumbers = BTreeMap<Language, BTreeMap<String, u64>>; | ||||||
| /// We extract these instead of subscriber counts because the YouTube API
 | /// We extract these instead of subscriber counts because the YouTube API
 | ||||||
| /// outputs view counts both in approximated and exact format, so we can use
 | /// outputs view counts both in approximated and exact format, so we can use
 | ||||||
| /// the exact counts to figure out the tokens.
 | /// the exact counts to figure out the tokens.
 | ||||||
| pub async fn collect_large_numbers(concurrency: usize) { | pub async fn collect_large_numbers(project_root: &Path, concurrency: usize) { | ||||||
|     let json_path = path!(*DICT_DIR / "large_number_samples_all.json"); |     let json_path = path!(project_root / "testfiles" / "dict" / "large_number_samples.json"); | ||||||
|     let rp = RustyPipe::new(); |     let json_path_all = | ||||||
|  |         path!(project_root / "testfiles" / "dict" / "large_number_samples_all.json"); | ||||||
| 
 | 
 | ||||||
|     let channels = [ |     let channels = [ | ||||||
|         "UCq-Fj5jknLsUf-MWSy4_brA", // 10e8 (241M)
 |         "UCq-Fj5jknLsUf-MWSy4_brA", // 10e8 (225M)
 | ||||||
|         "UCcdwLMPsaU2ezNSJU1nFoBQ", // 10e7 (67M)
 |         "UCcdwLMPsaU2ezNSJU1nFoBQ", // 10e7 (60M)
 | ||||||
|         "UC6mIxFTvXkWQVEHPsEdflzQ", // 10e6 (1.8M)
 |         "UC6mIxFTvXkWQVEHPsEdflzQ", // 10e6 (1.7M)
 | ||||||
|         "UCD0y51PJfvkZNe3y3FR5riw", // 10e5 (126K)
 |         "UCD0y51PJfvkZNe3y3FR5riw", // 10e5 (125K)
 | ||||||
|         "UCNcN0dW43zE0Om3278fjY8A", // 10e4 (33K)
 |         "UCNcN0dW43zE0Om3278fjY8A", // 10e4 (27K)
 | ||||||
|         "UC0QEucPrn0-Ddi3JBTcs5Kw", // 10e3 (5K)
 |         "UC0QEucPrn0-Ddi3JBTcs5Kw", // 10e3 (5K)
 | ||||||
|         "UCXvtcj9xUQhaqPaitFf2DqA", // (275)
 |         "UCXvtcj9xUQhaqPaitFf2DqA", // (170)
 | ||||||
|         "UCq-XMc01T641v-4P3hQYJWg", // (695)
 |         "UCq-XMc01T641v-4P3hQYJWg", // (636)
 | ||||||
|         "UCaZL4eLD7a30Fa8QI-sRi_g", // (31K)
 |  | ||||||
|         "UCO-dylEoJozPTxGYd8fTQxA", // (5)
 |  | ||||||
|         "UCQXYK94vDqOEkPbTCyL0OjA", // (1)
 |  | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|     // YTM outputs the subscriber count in a shortened format in some languages
 |     let collected_numbers_all: BTreeMap<Language, BTreeMap<String, u64>> = stream::iter(LANGUAGES) | ||||||
|     let music_channels = [ |         .map(|lang| async move { | ||||||
|         "UC_1N84buVNgR_-3gDZ9Jtxg", // 10e8 (158M)
 |  | ||||||
|         "UCRw0x9_EfawqmgDI2IgQLLg", // 10e7 (29M)
 |  | ||||||
|         "UChWu2clmvJ5wN_0Ic5dnqmw", // 10e6 (1.9M)
 |  | ||||||
|         "UCOYiPDuimprrGHgFy4_Fw8Q", // 10e5 (149K)
 |  | ||||||
|         "UC8nZf9WyVIxNMly_hy2PTyQ", // 10e4 (17K)
 |  | ||||||
|         "UCaltNL5XvZ7dKvBsBPi-gqg", // 10e3 (8K)
 |  | ||||||
|     ]; |  | ||||||
| 
 |  | ||||||
|     // Build a lookup table for the channel's subscriber counts
 |  | ||||||
|     let subscriber_counts: Arc<BTreeMap<String, u64>> = stream::iter(channels) |  | ||||||
|         .map(|c| { |  | ||||||
|             let rp = rp.query(); |  | ||||||
|             async move { |  | ||||||
|                 let channel = get_channel(&rp, c).await.unwrap(); |  | ||||||
| 
 |  | ||||||
|                 let n = util::parse_largenum_en(&channel.subscriber_count).unwrap(); |  | ||||||
|                 (c.to_owned(), n) |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .buffer_unordered(concurrency) |  | ||||||
|         .collect::<BTreeMap<_, _>>() |  | ||||||
|         .await |  | ||||||
|         .into(); |  | ||||||
| 
 |  | ||||||
|     let music_subscriber_counts: Arc<BTreeMap<String, u64>> = stream::iter(music_channels) |  | ||||||
|         .map(|c| { |  | ||||||
|             let rp = rp.query(); |  | ||||||
|             async move { |  | ||||||
|                 let subscriber_count = music_channel_subscribers(&rp, c).await.unwrap(); |  | ||||||
| 
 |  | ||||||
|                 let n = util::parse_largenum_en(&subscriber_count).unwrap(); |  | ||||||
|                 (c.to_owned(), n) |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .buffer_unordered(concurrency) |  | ||||||
|         .collect::<BTreeMap<_, _>>() |  | ||||||
|         .await |  | ||||||
|         .into(); |  | ||||||
| 
 |  | ||||||
|     let collected_numbers: CollectedNumbers = stream::iter(LANGUAGES) |  | ||||||
|         .map(|lang| { |  | ||||||
|             let rp = rp.query().lang(lang); |  | ||||||
|             let subscriber_counts = subscriber_counts.clone(); |  | ||||||
|             let music_subscriber_counts = music_subscriber_counts.clone(); |  | ||||||
|             async move { |  | ||||||
|             let mut entry = BTreeMap::new(); |             let mut entry = BTreeMap::new(); | ||||||
| 
 | 
 | ||||||
|             for (n, ch_id) in channels.iter().enumerate() { |             for (n, ch_id) in channels.iter().enumerate() { | ||||||
|                     let channel = get_channel(&rp, ch_id) |                 let channel = get_channel(ch_id, lang) | ||||||
|                     .await |                     .await | ||||||
|                     .context(format!("{lang}-{n}")) |                     .context(format!("{lang}-{n}")) | ||||||
|                     .unwrap(); |                     .unwrap(); | ||||||
| 
 | 
 | ||||||
|                 channel.view_counts.iter().for_each(|(num, txt)| { |                 channel.view_counts.iter().for_each(|(num, txt)| { | ||||||
|                         entry.insert(txt.clone(), *num); |                     entry.insert(txt.to_owned(), *num); | ||||||
|                 }); |                 }); | ||||||
|                     entry.insert(channel.subscriber_count, subscriber_counts[*ch_id]); |  | ||||||
| 
 | 
 | ||||||
|                 println!("collected {lang}-{n}"); |                 println!("collected {lang}-{n}"); | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|                 for (n, ch_id) in music_channels.iter().enumerate() { |  | ||||||
|                     let subscriber_count = music_channel_subscribers(&rp, ch_id) |  | ||||||
|                         .await |  | ||||||
|                         .context(format!("{lang}-music-{n}")) |  | ||||||
|                         .unwrap(); |  | ||||||
|                     entry.insert(subscriber_count, music_subscriber_counts[*ch_id]); |  | ||||||
|                     println!("collected {lang}-music-{n}"); |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|             (lang, entry) |             (lang, entry) | ||||||
|             } |  | ||||||
|         }) |         }) | ||||||
|         .buffer_unordered(concurrency) |         .buffer_unordered(concurrency) | ||||||
|         .collect() |         .collect() | ||||||
|         .await; |         .await; | ||||||
| 
 | 
 | ||||||
|  |     let collected_numbers: CollectedNumbers = collected_numbers_all | ||||||
|  |         .iter() | ||||||
|  |         .map(|(lang, entry)| { | ||||||
|  |             let mut e2 = BTreeMap::new(); | ||||||
|  |             entry.iter().for_each(|(txt, num)| { | ||||||
|  |                 e2.insert(get_mag(*num), (txt.to_owned(), *num)); | ||||||
|  |             }); | ||||||
|  |             (*lang, e2) | ||||||
|  |         }) | ||||||
|  |         .collect(); | ||||||
|  | 
 | ||||||
|     let file = File::create(json_path).unwrap(); |     let file = File::create(json_path).unwrap(); | ||||||
|     serde_json::to_writer_pretty(file, &collected_numbers).unwrap(); |     serde_json::to_writer_pretty(file, &collected_numbers).unwrap(); | ||||||
|  | 
 | ||||||
|  |     let file = File::create(json_path_all).unwrap(); | ||||||
|  |     serde_json::to_writer_pretty(file, &collected_numbers_all).unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Attempt to parse the numbers collected by `collect-large-numbers`
 | /// Attempt to parse the numbers collected by `collect-large-numbers`
 | ||||||
| /// and write the results to `dictionary.json`.
 | /// and write the results to `dictionary.json`.
 | ||||||
| pub fn write_samples_to_dict() { | pub fn write_samples_to_dict(project_root: &Path) { | ||||||
|     let json_path = path!(*DICT_DIR / "large_number_samples.json"); |     /* | ||||||
|  |     Manual corrections: | ||||||
|  |     as | ||||||
|  |     "কোঃটা": 9, | ||||||
|  |     "নিঃটা": 6, | ||||||
|  |     "নিযুতটা": 6, | ||||||
|  |     "লাখটা": 5, | ||||||
|  |     "হাজাৰটা": 3 | ||||||
|  | 
 | ||||||
|  |     ar | ||||||
|  |     "ألف": 3, | ||||||
|  |     "آلاف": 3, | ||||||
|  |     "مليار": 9, | ||||||
|  |     "مليون": 6 | ||||||
|  | 
 | ||||||
|  |     bn | ||||||
|  |     "লাটি": 5, | ||||||
|  |     "শত": 2, | ||||||
|  |     "হাটি": 3, | ||||||
|  |     "কোটি": 7 | ||||||
|  | 
 | ||||||
|  |     es/es-US | ||||||
|  |     "mil": 3, | ||||||
|  |     "M": 6 | ||||||
|  |     */ | ||||||
|  | 
 | ||||||
|  |     let json_path = path!(project_root / "testfiles" / "dict" / "large_number_samples.json"); | ||||||
| 
 | 
 | ||||||
|     let json_file = File::open(json_path).unwrap(); |     let json_file = File::open(json_path).unwrap(); | ||||||
|     let collected_nums: CollectedNumbers = |     let collected_nums: CollectedNumbers = | ||||||
|         serde_json::from_reader(BufReader::new(json_file)).unwrap(); |         serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|     let mut dict = util::read_dict(); |     let mut dict = util::read_dict(project_root); | ||||||
|     let langs = dict.keys().copied().collect::<Vec<_>>(); |     let langs = dict.keys().map(|k| k.to_owned()).collect::<Vec<_>>(); | ||||||
| 
 | 
 | ||||||
|     static POINT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\d(\.|,)\d{1,3}(?:\D|$)").unwrap()); |     static POINT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\d(\.|,)\d{1,3}(?:\D|$)").unwrap()); | ||||||
| 
 | 
 | ||||||
|  | @ -157,9 +132,11 @@ pub fn write_samples_to_dict() { | ||||||
|         let mut e_langs = dict_entry.equivalent.clone(); |         let mut e_langs = dict_entry.equivalent.clone(); | ||||||
|         e_langs.push(lang); |         e_langs.push(lang); | ||||||
| 
 | 
 | ||||||
|         let comma_decimal = collected_nums[&lang] |         let comma_decimal = collected_nums | ||||||
|  |             .get(&lang) | ||||||
|  |             .unwrap() | ||||||
|             .iter() |             .iter() | ||||||
|             .find_map(|(txt, val)| { |             .find_map(|(mag, (txt, _))| { | ||||||
|                 let point = POINT_REGEX |                 let point = POINT_REGEX | ||||||
|                     .captures(txt) |                     .captures(txt) | ||||||
|                     .map(|c| c.get(1).unwrap().as_str()); |                     .map(|c| c.get(1).unwrap().as_str()); | ||||||
|  | @ -169,14 +146,16 @@ pub fn write_samples_to_dict() { | ||||||
|                     // If the number parsed from all digits has the same order of
 |                     // If the number parsed from all digits has the same order of
 | ||||||
|                     // magnitude as the actual number, it must be a separator.
 |                     // magnitude as the actual number, it must be a separator.
 | ||||||
|                     // Otherwise it is a decimal point
 |                     // Otherwise it is a decimal point
 | ||||||
|                     return Some((get_mag(num_all) == get_mag(*val)) ^ (point == ",")); |                     return Some((get_mag(num_all) == *mag) ^ (point == ",")); | ||||||
|                 } |                 } | ||||||
| 
 |  | ||||||
|                 None |                 None | ||||||
|             }) |             }) | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|         let decimal_point = if comma_decimal { "," } else { "." }; |         let decimal_point = match comma_decimal { | ||||||
|  |             true => ",", | ||||||
|  |             false => ".", | ||||||
|  |         }; | ||||||
| 
 | 
 | ||||||
|         // Search for tokens
 |         // Search for tokens
 | ||||||
| 
 | 
 | ||||||
|  | @ -186,7 +165,6 @@ pub fn write_samples_to_dict() { | ||||||
|         // If the token is found again with a different derived order of magnitude,
 |         // If the token is found again with a different derived order of magnitude,
 | ||||||
|         // its value in the map is set to None.
 |         // its value in the map is set to None.
 | ||||||
|         let mut found_tokens: HashMap<String, Option<u8>> = HashMap::new(); |         let mut found_tokens: HashMap<String, Option<u8>> = HashMap::new(); | ||||||
|         let mut found_nd_tokens: HashMap<String, Option<u8>> = HashMap::new(); |  | ||||||
| 
 | 
 | ||||||
|         let mut insert_token = |token: String, mag: u8| { |         let mut insert_token = |token: String, mag: u8| { | ||||||
|             let found_token = found_tokens.entry(token).or_insert(match mag { |             let found_token = found_tokens.entry(token).or_insert(match mag { | ||||||
|  | @ -201,38 +179,23 @@ pub fn write_samples_to_dict() { | ||||||
|             } |             } | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let mut insert_nd_token = |token: String, n: Option<u8>| { |  | ||||||
|             let found_token = found_nd_tokens.entry(token).or_insert(n); |  | ||||||
| 
 |  | ||||||
|             if let Some(f) = found_token { |  | ||||||
|                 if Some(*f) != n { |  | ||||||
|                     *found_token = None; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         for lang in e_langs { |         for lang in e_langs { | ||||||
|             let entry = collected_nums.get(&lang).unwrap(); |             let entry = collected_nums.get(&lang).unwrap(); | ||||||
| 
 | 
 | ||||||
|             for (txt, val) in entry.iter() { |             entry.iter().for_each(|(mag, (txt, _))| { | ||||||
|                 let filtered = util::filter_largenumstr(txt); |                 let filtered = util::filter_largenumstr(txt); | ||||||
|                 let mag = get_mag(*val); |  | ||||||
| 
 | 
 | ||||||
|                 let tokens: Vec<String> = if dict_entry.by_char || lang == Language::Ko { |                 let tokens: Vec<String> = match dict_entry.by_char { | ||||||
|                     filtered.chars().map(|c| c.to_string()).collect() |                     true => filtered.chars().map(|c| c.to_string()).collect(), | ||||||
|                 } else { |                     false => filtered.split_whitespace().map(|c| c.to_string()).collect(), | ||||||
|                     filtered |  | ||||||
|                         .split_whitespace() |  | ||||||
|                         .map(std::string::ToString::to_string) |  | ||||||
|                         .collect() |  | ||||||
|                 }; |                 }; | ||||||
| 
 | 
 | ||||||
|                 match util::parse_numeric::<u64>(txt.split(decimal_point).next().unwrap()) { |                 let num_before_point = | ||||||
|                     Ok(num_before_point) => { |                     util::parse_numeric::<u64>(txt.split(decimal_point).next().unwrap()).unwrap(); | ||||||
|                 let mag_before_point = get_mag(num_before_point); |                 let mag_before_point = get_mag(num_before_point); | ||||||
|                 let mut mag_remaining = mag - mag_before_point; |                 let mut mag_remaining = mag - mag_before_point; | ||||||
| 
 | 
 | ||||||
|                         for t in &tokens { |                 tokens.iter().for_each(|t| { | ||||||
|                     // These tokens are correct in all languages
 |                     // These tokens are correct in all languages
 | ||||||
|                     // and are used to parse combined prefixes like `1.1K crore` (en-IN)
 |                     // and are used to parse combined prefixes like `1.1K crore` (en-IN)
 | ||||||
|                     let known_tmag: u8 = if t.len() == 1 { |                     let known_tmag: u8 = if t.len() == 1 { | ||||||
|  | @ -252,26 +215,10 @@ pub fn write_samples_to_dict() { | ||||||
|                             .checked_sub(known_tmag) |                             .checked_sub(known_tmag) | ||||||
|                             .expect("known magnitude incorrect"); |                             .expect("known magnitude incorrect"); | ||||||
|                     } else { |                     } else { | ||||||
|                                 insert_token(t.clone(), mag_remaining); |                         insert_token(t.to_owned(), mag_remaining); | ||||||
|                             } |  | ||||||
|                             insert_nd_token(t.clone(), None); |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                     Err(e) => { |  | ||||||
|                         if matches!(e.kind(), std::num::IntErrorKind::Empty) { |  | ||||||
|                             // Text does not contain any digits, search for nd_tokens
 |  | ||||||
|                             for t in &tokens { |  | ||||||
|                                 insert_nd_token( |  | ||||||
|                                     t.clone(), |  | ||||||
|                                     Some((*val).try_into().expect("nd_token value too large")), |  | ||||||
|                                 ); |  | ||||||
|                             } |  | ||||||
|                         } else { |  | ||||||
|                             panic!("{e}, txt: {txt}") |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                     } |                     } | ||||||
|  |                 }); | ||||||
|  |             }); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // Insert collected data into dictionary
 |         // Insert collected data into dictionary
 | ||||||
|  | @ -279,10 +226,6 @@ pub fn write_samples_to_dict() { | ||||||
|             .into_iter() |             .into_iter() | ||||||
|             .filter_map(|(k, v)| v.map(|v| (k, v))) |             .filter_map(|(k, v)| v.map(|v| (k, v))) | ||||||
|             .collect(); |             .collect(); | ||||||
|         dict_entry.number_nd_tokens = found_nd_tokens |  | ||||||
|             .into_iter() |  | ||||||
|             .filter_map(|(k, v)| v.map(|v| (k, v))) |  | ||||||
|             .collect(); |  | ||||||
|         dict_entry.comma_decimal = comma_decimal; |         dict_entry.comma_decimal = comma_decimal; | ||||||
| 
 | 
 | ||||||
|         // Check for duplicates
 |         // Check for duplicates
 | ||||||
|  | @ -290,13 +233,9 @@ pub fn write_samples_to_dict() { | ||||||
|         if !dict_entry.number_tokens.values().all(|x| uniq.insert(x)) { |         if !dict_entry.number_tokens.values().all(|x| uniq.insert(x)) { | ||||||
|             println!("Warning: collected duplicate tokens for {lang}"); |             println!("Warning: collected duplicate tokens for {lang}"); | ||||||
|         } |         } | ||||||
|         let mut uniq = HashSet::new(); |  | ||||||
|         if !dict_entry.number_nd_tokens.values().all(|x| uniq.insert(x)) { |  | ||||||
|             println!("Warning: collected duplicate nd_tokens for {lang}"); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     util::write_dict(dict); |     util::write_dict(project_root, &dict); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn get_mag(n: u64) -> u8 { | fn get_mag(n: u64) -> u8 { | ||||||
|  | @ -304,147 +243,145 @@ fn get_mag(n: u64) -> u8 { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /* | /* | ||||||
| YouTube Music channel data | YouTube channel videos response | ||||||
| */ | */ | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct MusicChannel { | struct Channel { | ||||||
|     header: MusicHeader, |     contents: Contents, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct MusicHeader { | struct Contents { | ||||||
|     #[serde(alias = "musicVisualHeaderRenderer")] |     two_column_browse_results_renderer: TabsRenderer, | ||||||
|     music_immersive_header_renderer: MusicHeaderRenderer, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[serde_as] | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct MusicHeaderRenderer { | struct TabsRenderer { | ||||||
|     subscription_button: SubscriptionButton, |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     tabs: Vec<TabRendererWrap>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct SubscriptionButton { | struct TabRendererWrap { | ||||||
|     subscribe_button_renderer: SubscriptionButtonRenderer, |     tab_renderer: TabRenderer, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct SubscriptionButtonRenderer { | struct TabRenderer { | ||||||
|     subscriber_count_text: TextRuns, |     content: SectionListRendererWrap, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug)] | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct SectionListRendererWrap { | ||||||
|  |     section_list_renderer: SectionListRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct SectionListRenderer { | ||||||
|  |     contents: Vec<ItemSectionRendererWrap>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct ItemSectionRendererWrap { | ||||||
|  |     item_section_renderer: ItemSectionRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct ItemSectionRenderer { | ||||||
|  |     contents: Vec<GridRendererWrap>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct GridRendererWrap { | ||||||
|  |     grid_renderer: GridRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct GridRenderer { | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     items: Vec<VideoListItem>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct VideoListItem { | ||||||
|  |     grid_video_renderer: GridVideoRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | struct GridVideoRenderer { | ||||||
|  |     /// `24,194 views`
 | ||||||
|  |     view_count_text: Text, | ||||||
|  |     /// `19K views`
 | ||||||
|  |     short_view_count_text: Text, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug)] | ||||||
| struct ChannelData { | struct ChannelData { | ||||||
|     view_counts: BTreeMap<u64, String>, |     view_counts: Vec<(u64, String)>, | ||||||
|     subscriber_count: String, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn get_channel(query: &RustyPipeQuery, channel_id: &str) -> Result<ChannelData> { | async fn get_channel(channel_id: &str, lang: Language) -> Result<ChannelData> { | ||||||
|     let resp = query |     let client = Client::new(); | ||||||
|         .raw( |  | ||||||
|             ClientType::Desktop, |  | ||||||
|             "browse", |  | ||||||
|             &QBrowse { |  | ||||||
|                 browse_id: channel_id, |  | ||||||
|                 params: Some("EgZ2aWRlb3MYASAAMAE"), |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
| 
 | 
 | ||||||
|     let channel = serde_json::from_str::<Channel>(&resp)?; |     let body = format!( | ||||||
| 
 |         "{}{}{}{}{}", | ||||||
|     let tab = &channel.contents.two_column_browse_results_renderer.tabs[0] |         r##"{"context":{"client":{"clientName":"WEB","clientVersion":"2.20220914.06.00","platform":"DESKTOP","originalUrl":"https://www.youtube.com/","hl":""##, | ||||||
|         .tab_renderer |         lang, | ||||||
|         .content |         r##"","gl":"US"},"request":{"internalExperimentFlags":[],"useSsl":true},"user":{"lockedSafetyMode":false}},"params":"EgZ2aWRlb3MYASAAMAE%3D","browseId":""##, | ||||||
|         .rich_grid_renderer; |         channel_id, | ||||||
| 
 |         "\"}" | ||||||
|     let popular_token = tab.header.as_ref().and_then(|h| { |  | ||||||
|         h.feed_filter_chip_bar_renderer.contents.get(1).map(|c| { |  | ||||||
|             c.chip_cloud_chip_renderer |  | ||||||
|                 .navigation_endpoint |  | ||||||
|                 .continuation_command |  | ||||||
|                 .token |  | ||||||
|                 .clone() |  | ||||||
|         }) |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     let mut view_counts: BTreeMap<u64, String> = tab |  | ||||||
|         .contents |  | ||||||
|         .iter() |  | ||||||
|         .map(|itm| { |  | ||||||
|             let v = &itm.rich_item_renderer.content.video_renderer; |  | ||||||
|             ( |  | ||||||
|                 util::parse_numeric(&v.view_count_text.text).unwrap_or_default(), |  | ||||||
|                 v.short_view_count_text.text.clone(), |  | ||||||
|             ) |  | ||||||
|         }) |  | ||||||
|         .collect(); |  | ||||||
| 
 |  | ||||||
|     if let Some(popular_token) = popular_token { |  | ||||||
|         let resp = query |  | ||||||
|             .raw( |  | ||||||
|                 ClientType::Desktop, |  | ||||||
|                 "browse", |  | ||||||
|                 &QCont { |  | ||||||
|                     continuation: &popular_token, |  | ||||||
|                 }, |  | ||||||
|             ) |  | ||||||
|             .await?; |  | ||||||
| 
 |  | ||||||
|         let continuation = serde_json::from_str::<ContinuationResponse>(&resp)?; |  | ||||||
| 
 |  | ||||||
|         for action in &continuation.on_response_received_actions { |  | ||||||
|             action |  | ||||||
|                 .reload_continuation_items_command |  | ||||||
|                 .continuation_items |  | ||||||
|                 .iter() |  | ||||||
|                 .for_each(|itm| { |  | ||||||
|                     let v = &itm.rich_item_renderer.content.video_renderer; |  | ||||||
|                     view_counts.insert( |  | ||||||
|                         util::parse_numeric(&v.view_count_text.text).unwrap(), |  | ||||||
|                         v.short_view_count_text.text.clone(), |  | ||||||
|     ); |     ); | ||||||
|                 }); | 
 | ||||||
|         } |     let resp = client | ||||||
|     } |         .post("https://www.youtube.com/youtubei/v1/browse?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false") | ||||||
|  |         .header(header::CONTENT_TYPE, "application/json") | ||||||
|  |         .body(body) | ||||||
|  |         .send().await? | ||||||
|  |         .error_for_status()?; | ||||||
|  | 
 | ||||||
|  |     let channel = resp.json::<Channel>().await?; | ||||||
| 
 | 
 | ||||||
|     Ok(ChannelData { |     Ok(ChannelData { | ||||||
|         view_counts, |         view_counts: channel | ||||||
|         subscriber_count: channel |             .contents | ||||||
|             .header |             .two_column_browse_results_renderer | ||||||
|             .c4_tabbed_header_renderer |             .tabs | ||||||
|             .subscriber_count_text |             .get(0) | ||||||
|             .text, |             .map(|tab| { | ||||||
|  |                 tab.tab_renderer.content.section_list_renderer.contents[0] | ||||||
|  |                     .item_section_renderer | ||||||
|  |                     .contents[0] | ||||||
|  |                     .grid_renderer | ||||||
|  |                     .items | ||||||
|  |                     .iter() | ||||||
|  |                     .map(|itm| { | ||||||
|  |                         ( | ||||||
|  |                             util::parse_numeric(&itm.grid_video_renderer.view_count_text.text) | ||||||
|  |                                 .unwrap(), | ||||||
|  |                             itm.grid_video_renderer | ||||||
|  |                                 .short_view_count_text | ||||||
|  |                                 .text | ||||||
|  |                                 .to_owned(), | ||||||
|  |                         ) | ||||||
|  |                     }) | ||||||
|  |                     .collect() | ||||||
|  |             }) | ||||||
|  |             .unwrap_or_default(), | ||||||
|     }) |     }) | ||||||
| } | } | ||||||
| 
 |  | ||||||
| async fn music_channel_subscribers(query: &RustyPipeQuery, channel_id: &str) -> Result<String> { |  | ||||||
|     let resp = query |  | ||||||
|         .raw( |  | ||||||
|             ClientType::DesktopMusic, |  | ||||||
|             "browse", |  | ||||||
|             &QBrowse { |  | ||||||
|                 browse_id: channel_id, |  | ||||||
|                 params: None, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
| 
 |  | ||||||
|     let channel = serde_json::from_str::<MusicChannel>(&resp)?; |  | ||||||
|     channel |  | ||||||
|         .header |  | ||||||
|         .music_immersive_header_renderer |  | ||||||
|         .subscription_button |  | ||||||
|         .subscribe_button_renderer |  | ||||||
|         .subscriber_count_text |  | ||||||
|         .runs |  | ||||||
|         .into_iter() |  | ||||||
|         .next() |  | ||||||
|         .map(|t| t.text) |  | ||||||
|         .ok_or_else(|| anyhow::anyhow!("no text")) |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -3,18 +3,19 @@ use std::{ | ||||||
|     fs::File, |     fs::File, | ||||||
|     hash::Hash, |     hash::Hash, | ||||||
|     io::BufReader, |     io::BufReader, | ||||||
|  |     path::Path, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use futures_util::{stream, StreamExt}; | use futures::{stream, StreamExt}; | ||||||
| use ordered_hash_map::OrderedHashMap; |  | ||||||
| use path_macro::path; | use path_macro::path; | ||||||
| use rustypipe::{ | use rustypipe::{ | ||||||
|     client::RustyPipe, |     client::RustyPipe, | ||||||
|     param::{Language, LANGUAGES}, |     param::{locale::LANGUAGES, Language}, | ||||||
|  |     timeago::{self, TimeAgo}, | ||||||
| }; | }; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
| use crate::util::{self, DICT_DIR}; | use crate::util; | ||||||
| 
 | 
 | ||||||
| type CollectedDates = BTreeMap<Language, BTreeMap<DateCase, String>>; | type CollectedDates = BTreeMap<Language, BTreeMap<DateCase, String>>; | ||||||
| 
 | 
 | ||||||
|  | @ -61,14 +62,17 @@ enum DateCase { | ||||||
| ///
 | ///
 | ||||||
| /// Because the relative dates change with time, the first three playlists
 | /// Because the relative dates change with time, the first three playlists
 | ||||||
| /// have to checked and eventually changed before running the program.
 | /// have to checked and eventually changed before running the program.
 | ||||||
| pub async fn collect_dates(concurrency: usize) { | pub async fn collect_dates(project_root: &Path, concurrency: usize) { | ||||||
|     let json_path = path!(*DICT_DIR / "playlist_samples.json"); |     let json_path = path!(project_root / "testfiles" / "dict" / "playlist_samples.json"); | ||||||
| 
 | 
 | ||||||
|     // These are the sample playlists
 |     // These are the sample playlists
 | ||||||
|     let cases = [ |     let cases = [ | ||||||
|         (DateCase::Today, "PL3oW2tjiIxvQ98ZTLhBh5soCbE1mC3uAT"), |         ( | ||||||
|         (DateCase::Yesterday, "PLGBuKfnErZlCkRRgt06em8nbXvcV5Sae7"), |             DateCase::Today, | ||||||
|         (DateCase::Ago, "PLAQ7nLSEnhWTEihjeM1I-ToPDJEKfZHZu"), |             "RDCLAK5uy_kj3rhiar1LINmyDcuFnXihEO0K1NQa2jI", | ||||||
|  |         ), | ||||||
|  |         (DateCase::Yesterday, "PL7zsB-C3aNu2yRY2869T0zj1FhtRIu5am"), | ||||||
|  |         (DateCase::Ago, "PLmB6td997u3kUOrfFwkULZ910ho44oQSy"), | ||||||
|         (DateCase::Jan, "PL1J-6JOckZtFjcni6Xj1pLYglJp6JCpKD"), |         (DateCase::Jan, "PL1J-6JOckZtFjcni6Xj1pLYglJp6JCpKD"), | ||||||
|         (DateCase::Feb, "PL1J-6JOckZtETrbzwZE7mRIIK6BzWNLAs"), |         (DateCase::Feb, "PL1J-6JOckZtETrbzwZE7mRIIK6BzWNLAs"), | ||||||
|         (DateCase::Mar, "PL1J-6JOckZtG3AVdvBXhMO64mB2k3BtKi"), |         (DateCase::Mar, "PL1J-6JOckZtG3AVdvBXhMO64mB2k3BtKi"), | ||||||
|  | @ -86,7 +90,6 @@ pub async fn collect_dates(concurrency: usize) { | ||||||
|     let rp = RustyPipe::new(); |     let rp = RustyPipe::new(); | ||||||
|     let collected_dates = stream::iter(LANGUAGES) |     let collected_dates = stream::iter(LANGUAGES) | ||||||
|         .map(|lang| { |         .map(|lang| { | ||||||
|             println!("{lang}"); |  | ||||||
|             let rp = rp.clone(); |             let rp = rp.clone(); | ||||||
|             async move { |             async move { | ||||||
|                 let mut map: BTreeMap<DateCase, String> = BTreeMap::new(); |                 let mut map: BTreeMap<DateCase, String> = BTreeMap::new(); | ||||||
|  | @ -112,14 +115,14 @@ pub async fn collect_dates(concurrency: usize) { | ||||||
| ///
 | ///
 | ||||||
| /// The ND (no digit) tokens (today, tomorrow) of some languages cannot be
 | /// The ND (no digit) tokens (today, tomorrow) of some languages cannot be
 | ||||||
| /// parsed automatically and require manual work.
 | /// parsed automatically and require manual work.
 | ||||||
| pub fn write_samples_to_dict() { | pub fn write_samples_to_dict(project_root: &Path) { | ||||||
|     let json_path = path!(*DICT_DIR / "playlist_samples.json"); |     let json_path = path!(project_root / "testfiles" / "dict" / "playlist_samples.json"); | ||||||
| 
 | 
 | ||||||
|     let json_file = File::open(json_path).unwrap(); |     let json_file = File::open(json_path).unwrap(); | ||||||
|     let collected_dates: CollectedDates = |     let collected_dates: CollectedDates = | ||||||
|         serde_json::from_reader(BufReader::new(json_file)).unwrap(); |         serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|     let mut dict = util::read_dict(); |     let mut dict = util::read_dict(project_root); | ||||||
|     let langs = dict.keys().copied().collect::<Vec<_>>(); |     let langs = dict.keys().map(|k| k.to_owned()).collect::<Vec<_>>(); | ||||||
| 
 | 
 | ||||||
|     let months = [ |     let months = [ | ||||||
|         DateCase::Jan, |         DateCase::Jan, | ||||||
|  | @ -160,18 +163,30 @@ pub fn write_samples_to_dict() { | ||||||
|             .for_each(|l| datestr_tables.push(collected_dates.get(l).unwrap())); |             .for_each(|l| datestr_tables.push(collected_dates.get(l).unwrap())); | ||||||
| 
 | 
 | ||||||
|         let dict_entry = dict.entry(lang).or_default(); |         let dict_entry = dict.entry(lang).or_default(); | ||||||
|         let mut num_order = String::new(); |         let mut num_order = "".to_owned(); | ||||||
| 
 | 
 | ||||||
|         let collect_nd_tokens = !matches!( |         let collect_nd_tokens = !matches!( | ||||||
|             lang, |             lang, | ||||||
|             // ND tokens of these languages must be edited manually
 |             // ND tokens of these languages must be edited manually
 | ||||||
|             Language::Ja | Language::ZhCn | Language::ZhHk | Language::ZhTw |             Language::Ja | ||||||
|  |             | Language::ZhCn | ||||||
|  |             | Language::ZhHk | ||||||
|  |             | Language::ZhTw | ||||||
|  |             | Language::Ko | ||||||
|  |             | Language::Gu | ||||||
|  |             | Language::Pa | ||||||
|  |             | Language::Ur | ||||||
|  |             | Language::Uz | ||||||
|  |             | Language::Te | ||||||
|  |             | Language::PtPt | ||||||
|  |             // Singhalese YT translation has an error (today == tomorrow)
 | ||||||
|  |             | Language::Si | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         dict_entry.months = BTreeMap::new(); |         dict_entry.months = BTreeMap::new(); | ||||||
| 
 | 
 | ||||||
|         if collect_nd_tokens { |         if collect_nd_tokens { | ||||||
|             dict_entry.timeago_nd_tokens = OrderedHashMap::new(); |             dict_entry.timeago_nd_tokens = BTreeMap::new(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         for datestr_table in &datestr_tables { |         for datestr_table in &datestr_tables { | ||||||
|  | @ -197,6 +212,20 @@ pub fn write_samples_to_dict() { | ||||||
|                 parse(datestr_table.get(&DateCase::Jan).unwrap(), 0); |                 parse(datestr_table.get(&DateCase::Jan).unwrap(), 0); | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|  |             // n days ago
 | ||||||
|  |             { | ||||||
|  |                 let datestr = datestr_table.get(&DateCase::Ago).unwrap(); | ||||||
|  |                 let tago = timeago::parse_timeago(lang, datestr); | ||||||
|  |                 assert_eq!( | ||||||
|  |                     tago, | ||||||
|  |                     Some(TimeAgo { | ||||||
|  |                         n: 3, | ||||||
|  |                         unit: timeago::TimeUnit::Day | ||||||
|  |                     }), | ||||||
|  |                     "lang: {lang}, txt: {datestr}" | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|             // Absolute dates (Jan 3, 2020)
 |             // Absolute dates (Jan 3, 2020)
 | ||||||
|             months.iter().enumerate().for_each(|(n, m)| { |             months.iter().enumerate().for_each(|(n, m)| { | ||||||
|                 let datestr = datestr_table.get(m).unwrap(); |                 let datestr = datestr_table.get(m).unwrap(); | ||||||
|  | @ -237,36 +266,38 @@ pub fn write_samples_to_dict() { | ||||||
|                     }); |                     }); | ||||||
|             }); |             }); | ||||||
| 
 | 
 | ||||||
|             for (word, m) in &month_words { |             month_words.iter().for_each(|(word, m)| { | ||||||
|                 if *m != 0 { |                 if *m != 0 { | ||||||
|                     dict_entry.months.insert(word.clone(), *m as u8); |                     dict_entry.months.insert(word.to_owned(), *m as u8); | ||||||
|                 }; |                 }; | ||||||
|             } |             }); | ||||||
| 
 | 
 | ||||||
|             if collect_nd_tokens { |             if collect_nd_tokens { | ||||||
|                 for (word, n) in &td_words { |                 td_words.iter().for_each(|(word, n)| { | ||||||
|                     match n { |                     match n { | ||||||
|                         // Today
 |                         // Today
 | ||||||
|                         1 => { |                         1 => { | ||||||
|                             dict_entry |                             dict_entry | ||||||
|                                 .timeago_nd_tokens |                                 .timeago_nd_tokens | ||||||
|                                 .insert(word.clone(), "0D".to_owned()); |                                 .insert(word.to_owned(), "0D".to_owned()); | ||||||
|                         } |                         } | ||||||
|                         // Yesterday
 |                         // Yesterday
 | ||||||
|                         2 => { |                         2 => { | ||||||
|                             dict_entry |                             dict_entry | ||||||
|                                 .timeago_nd_tokens |                                 .timeago_nd_tokens | ||||||
|                                 .insert(word.clone(), "1D".to_owned()); |                                 .insert(word.to_owned(), "1D".to_owned()); | ||||||
|                         } |                         } | ||||||
|                         _ => {} |                         _ => {} | ||||||
|                     }; |                     }; | ||||||
|                 } |                 }); | ||||||
| 
 | 
 | ||||||
|                 if datestr_tables.len() == 1 && dict_entry.timeago_nd_tokens.len() > 2 { |                 if datestr_tables.len() == 1 { | ||||||
|                     println!( |                     assert_eq!( | ||||||
|                         "INFO: {} has {} nd_tokens. Check manually.", |                         dict_entry.timeago_nd_tokens.len(), | ||||||
|  |                         2, | ||||||
|  |                         "lang: {}, nd_tokens: {:?}", | ||||||
|                         lang, |                         lang, | ||||||
|                         dict_entry.timeago_nd_tokens.len() |                         &dict_entry.timeago_nd_tokens | ||||||
|                     ); |                     ); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  | @ -274,5 +305,5 @@ pub fn write_samples_to_dict() { | ||||||
|         dict_entry.date_order = num_order; |         dict_entry.date_order = num_order; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     util::write_dict(dict); |     util::write_dict(project_root, &dict); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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() |  | ||||||
| } |  | ||||||
|  | @ -1,373 +0,0 @@ | ||||||
| use std::{ |  | ||||||
|     collections::{BTreeMap, HashMap}, |  | ||||||
|     fs::File, |  | ||||||
|     io::BufReader, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| use anyhow::Result; |  | ||||||
| use futures_util::{stream, StreamExt}; |  | ||||||
| use path_macro::path; |  | ||||||
| use rustypipe::{ |  | ||||||
|     client::{ClientType, RustyPipe, RustyPipeQuery}, |  | ||||||
|     param::{Language, LANGUAGES}, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| use crate::{ |  | ||||||
|     model::{Channel, QBrowse, TimeAgo, TimeUnit}, |  | ||||||
|     util::{self, DICT_DIR}, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| type CollectedDurations = BTreeMap<Language, BTreeMap<String, u32>>; |  | ||||||
| 
 |  | ||||||
| /// Collect the video duration texts in every supported language
 |  | ||||||
| /// and write them to `testfiles/dict/video_duration_samples.json`.
 |  | ||||||
| ///
 |  | ||||||
| /// The length of YouTube short videos is only available in textual form.
 |  | ||||||
| /// To parse it correctly, we need to collect samples of this text in every
 |  | ||||||
| /// language. We collect these samples from regular channel videos because these
 |  | ||||||
| /// include a textual duration in addition to the easy to parse "mm:ss"
 |  | ||||||
| /// duration format.
 |  | ||||||
| pub async fn collect_video_durations(concurrency: usize) { |  | ||||||
|     let json_path = path!(*DICT_DIR / "video_duration_samples.json"); |  | ||||||
|     let rp = RustyPipe::new(); |  | ||||||
| 
 |  | ||||||
|     let channels = [ |  | ||||||
|         "UCq-Fj5jknLsUf-MWSy4_brA", |  | ||||||
|         "UCMcS5ITpSohfr8Ppzlo4vKw", |  | ||||||
|         "UCXuqSBlHAE6Xw-yeJA0Tunw", |  | ||||||
|     ]; |  | ||||||
| 
 |  | ||||||
|     let durations: CollectedDurations = stream::iter(LANGUAGES) |  | ||||||
|         .map(|lang| { |  | ||||||
|             let rp = rp.query().lang(lang); |  | ||||||
|             async move { |  | ||||||
|                 let mut map = BTreeMap::new(); |  | ||||||
| 
 |  | ||||||
|                 for (n, ch_id) in channels.iter().enumerate() { |  | ||||||
|                     get_channel_vlengths(&rp, ch_id, &mut map).await.unwrap(); |  | ||||||
|                     println!("collected {lang}-{n}"); |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 // Since we are only parsing shorts durations, we do not need durations >= 1h
 |  | ||||||
|                 let map = map.into_iter().filter(|(_, v)| v < &3600).collect(); |  | ||||||
|                 (lang, map) |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .buffer_unordered(concurrency) |  | ||||||
|         .collect() |  | ||||||
|         .await; |  | ||||||
| 
 |  | ||||||
|     let file = File::create(json_path).unwrap(); |  | ||||||
|     serde_json::to_writer_pretty(file, &durations).unwrap(); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| pub fn parse_video_durations() { |  | ||||||
|     let json_path = path!(*DICT_DIR / "video_duration_samples.json"); |  | ||||||
|     let json_file = File::open(json_path).unwrap(); |  | ||||||
|     let durations: CollectedDurations = 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 mut e_langs = dict_entry.equivalent.clone(); |  | ||||||
|         e_langs.push(lang); |  | ||||||
| 
 |  | ||||||
|         for lang in e_langs { |  | ||||||
|             let mut words = HashMap::new(); |  | ||||||
| 
 |  | ||||||
|             fn check_add_word( |  | ||||||
|                 words: &mut HashMap<String, Option<TimeAgo>>, |  | ||||||
|                 by_char: bool, |  | ||||||
|                 val: u32, |  | ||||||
|                 expect: u32, |  | ||||||
|                 w: &str, |  | ||||||
|                 unit: TimeUnit, |  | ||||||
|             ) -> bool { |  | ||||||
|                 let ok = val == expect || val * 2 == expect; |  | ||||||
|                 if ok { |  | ||||||
|                     let mut ins = |w: &str, val: &mut TimeAgo| { |  | ||||||
|                         // Filter stop words
 |  | ||||||
|                         if matches!( |  | ||||||
|                             w, |  | ||||||
|                             "na" | "y" |  | ||||||
|                                 | "و" |  | ||||||
|                                 | "ja" |  | ||||||
|                                 | "et" |  | ||||||
|                                 | "e" |  | ||||||
|                                 | "i" |  | ||||||
|                                 | "և" |  | ||||||
|                                 | "og" |  | ||||||
|                                 | "en" |  | ||||||
|                                 | "и" |  | ||||||
|                                 | "a" |  | ||||||
|                                 | "és" |  | ||||||
|                                 | "ir" |  | ||||||
|                                 | "un" |  | ||||||
|                                 | "și" |  | ||||||
|                                 | "in" |  | ||||||
|                                 | "และ" |  | ||||||
|                                 | "\u{0456}" |  | ||||||
|                                 | "鐘" |  | ||||||
|                                 | "eta" |  | ||||||
|                                 | "અને" |  | ||||||
|                                 | "और" |  | ||||||
|                                 | "കൂടാതെ" |  | ||||||
|                                 | "සහ" |  | ||||||
|                         ) { |  | ||||||
|                             return; |  | ||||||
|                         } |  | ||||||
| 
 |  | ||||||
|                         let entry = words.entry(w.to_owned()).or_insert(Some(*val)); |  | ||||||
|                         if let Some(e) = entry { |  | ||||||
|                             if e != val { |  | ||||||
|                                 *entry = None; |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|                     }; |  | ||||||
| 
 |  | ||||||
|                     let mut val = TimeAgo { |  | ||||||
|                         n: (expect / val).try_into().unwrap(), |  | ||||||
|                         unit, |  | ||||||
|                     }; |  | ||||||
| 
 |  | ||||||
|                     if by_char { |  | ||||||
|                         w.chars().for_each(|c| { |  | ||||||
|                             if !c.is_whitespace() { |  | ||||||
|                                 ins(&c.to_string(), &mut val); |  | ||||||
|                             } |  | ||||||
|                         }); |  | ||||||
|                     } else { |  | ||||||
|                         w.split_whitespace().for_each(|w| ins(w, &mut val)); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                 ok |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             fn parse( |  | ||||||
|                 words: &mut HashMap<String, Option<TimeAgo>>, |  | ||||||
|                 lang: Language, |  | ||||||
|                 by_char: bool, |  | ||||||
|                 txt: &str, |  | ||||||
|                 d: u32, |  | ||||||
|             ) { |  | ||||||
|                 let (m, s) = split_duration(d); |  | ||||||
| 
 |  | ||||||
|                 let mut parts = |  | ||||||
|                     split_duration_txt(txt, matches!(lang, Language::Si | Language::Sw)) |  | ||||||
|                         .into_iter(); |  | ||||||
| 
 |  | ||||||
|                 let p1 = parts.next().unwrap(); |  | ||||||
|                 let p1_n = p1.digits.parse::<u32>().unwrap_or(1); |  | ||||||
|                 let p2: Option<DurationTxtSegment> = parts.next(); |  | ||||||
| 
 |  | ||||||
|                 match p2 { |  | ||||||
|                     Some(p2) => { |  | ||||||
|                         let p2_n = p2.digits.parse::<u32>().unwrap_or(1); |  | ||||||
| 
 |  | ||||||
|                         assert!( |  | ||||||
|                             check_add_word(words, by_char, p1_n, m, &p1.word, TimeUnit::Minute), |  | ||||||
|                             "{txt}: min parse error" |  | ||||||
|                         ); |  | ||||||
|                         assert!( |  | ||||||
|                             check_add_word(words, by_char, p2_n, s, &p2.word, TimeUnit::Second), |  | ||||||
|                             "{txt}: sec parse error" |  | ||||||
|                         ); |  | ||||||
|                     } |  | ||||||
|                     None => { |  | ||||||
|                         if s == 0 { |  | ||||||
|                             assert!( |  | ||||||
|                                 check_add_word(words, by_char, p1_n, m, &p1.word, TimeUnit::Minute), |  | ||||||
|                                 "{txt}: min parse error" |  | ||||||
|                             ); |  | ||||||
|                         } else if m == 0 { |  | ||||||
|                             assert!( |  | ||||||
|                                 check_add_word(words, by_char, p1_n, s, &p1.word, TimeUnit::Second), |  | ||||||
|                                 "{txt}: sec parse error" |  | ||||||
|                             ); |  | ||||||
|                         } else { |  | ||||||
|                             let p = txt |  | ||||||
|                                 .find([',', 'و']) |  | ||||||
|                                 .unwrap_or_else(|| panic!("`{txt}`: only 1 part")); |  | ||||||
|                             parse(words, lang, by_char, &txt[0..p], m); |  | ||||||
|                             parse(words, lang, by_char, &txt[p..], s); |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 assert!(parts.next().is_none(), "`{txt}`: more than 2 parts"); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             for (txt, d) in &durations[&lang] { |  | ||||||
|                 parse(&mut words, lang, dict_entry.by_char, txt, *d); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             for (k, v) in words { |  | ||||||
|                 if let Some(v) = v { |  | ||||||
|                     dict_entry.timeago_tokens.insert(k, v.to_string()); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     util::write_dict(dict); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn split_duration(d: u32) -> (u32, u32) { |  | ||||||
|     (d / 60, d % 60) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Default)] |  | ||||||
| struct DurationTxtSegment { |  | ||||||
|     digits: String, |  | ||||||
|     word: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn split_duration_txt(txt: &str, start_c: bool) -> Vec<DurationTxtSegment> { |  | ||||||
|     let mut segments = Vec::new(); |  | ||||||
| 
 |  | ||||||
|     // 1: parse digits, 2: parse word
 |  | ||||||
|     let mut state: u8 = 0; |  | ||||||
|     let mut seg = DurationTxtSegment::default(); |  | ||||||
| 
 |  | ||||||
|     for c in txt.chars() { |  | ||||||
|         if c.is_ascii_digit() { |  | ||||||
|             if state == 2 && (!seg.digits.is_empty() || (!start_c && segments.is_empty())) { |  | ||||||
|                 segments.push(seg); |  | ||||||
|                 seg = DurationTxtSegment::default(); |  | ||||||
|             } |  | ||||||
|             seg.digits.push(c); |  | ||||||
|             state = 1; |  | ||||||
|         } else { |  | ||||||
|             if (state == 1) && (!seg.word.is_empty() || (start_c && segments.is_empty())) { |  | ||||||
|                 segments.push(seg); |  | ||||||
|                 seg = DurationTxtSegment::default(); |  | ||||||
|             } |  | ||||||
|             if c != ',' { |  | ||||||
|                 c.to_lowercase().for_each(|c| seg.word.push(c)); |  | ||||||
|             } |  | ||||||
|             state = 2; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     if !seg.word.is_empty() || !seg.digits.is_empty() { |  | ||||||
|         segments.push(seg); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     segments |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn get_channel_vlengths( |  | ||||||
|     query: &RustyPipeQuery, |  | ||||||
|     channel_id: &str, |  | ||||||
|     map: &mut BTreeMap<String, u32>, |  | ||||||
| ) -> Result<()> { |  | ||||||
|     let resp = query |  | ||||||
|         .raw( |  | ||||||
|             ClientType::Desktop, |  | ||||||
|             "browse", |  | ||||||
|             &QBrowse { |  | ||||||
|                 browse_id: channel_id, |  | ||||||
|                 params: Some("EgZ2aWRlb3MYASAAMAE"), |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
| 
 |  | ||||||
|     let channel = serde_json::from_str::<Channel>(&resp)?; |  | ||||||
| 
 |  | ||||||
|     let tab = channel |  | ||||||
|         .contents |  | ||||||
|         .two_column_browse_results_renderer |  | ||||||
|         .tabs |  | ||||||
|         .into_iter() |  | ||||||
|         .next() |  | ||||||
|         .unwrap() |  | ||||||
|         .tab_renderer |  | ||||||
|         .content |  | ||||||
|         .rich_grid_renderer; |  | ||||||
| 
 |  | ||||||
|     tab.contents.into_iter().for_each(|c| { |  | ||||||
|         let lt = c.rich_item_renderer.content.video_renderer.length_text; |  | ||||||
|         let duration = util::parse_video_length(<.simple_text).unwrap(); |  | ||||||
|         map.insert(lt.accessibility.accessibility_data.label, duration); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] |  | ||||||
| enum PluralCategory { |  | ||||||
|     Zero, |  | ||||||
|     One, |  | ||||||
|     Two, |  | ||||||
|     Few, |  | ||||||
|     Many, |  | ||||||
|     Other, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl From<intl_pluralrules::PluralCategory> for PluralCategory { |  | ||||||
|     fn from(value: intl_pluralrules::PluralCategory) -> Self { |  | ||||||
|         match value { |  | ||||||
|             intl_pluralrules::PluralCategory::ZERO => Self::Zero, |  | ||||||
|             intl_pluralrules::PluralCategory::ONE => Self::One, |  | ||||||
|             intl_pluralrules::PluralCategory::TWO => Self::Two, |  | ||||||
|             intl_pluralrules::PluralCategory::FEW => Self::Few, |  | ||||||
|             intl_pluralrules::PluralCategory::MANY => Self::Many, |  | ||||||
|             intl_pluralrules::PluralCategory::OTHER => Self::Other, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
|     use super::*; |  | ||||||
| 
 |  | ||||||
|     use std::collections::HashSet; |  | ||||||
|     use std::io::BufReader; |  | ||||||
| 
 |  | ||||||
|     use intl_pluralrules::{PluralRuleType, PluralRules}; |  | ||||||
|     use unic_langid::LanguageIdentifier; |  | ||||||
| 
 |  | ||||||
|     /// Verify that the duration sample set covers all pluralization variants of the languages
 |  | ||||||
|     #[test] |  | ||||||
|     fn check_video_duration_samples() { |  | ||||||
|         let json_path = path!(*DICT_DIR / "video_duration_samples.json"); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
|         let durations: CollectedDurations = |  | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let mut failed = false; |  | ||||||
| 
 |  | ||||||
|         for (lang, durations) in durations { |  | ||||||
|             let ul: LanguageIdentifier = |  | ||||||
|                 lang.to_string().split('-').next().unwrap().parse().unwrap(); |  | ||||||
| 
 |  | ||||||
|             let pr = PluralRules::create(ul, PluralRuleType::CARDINAL) |  | ||||||
|                 .unwrap_or_else(|_| panic!("{}", lang.to_string())); |  | ||||||
| 
 |  | ||||||
|             let mut plurals_m: HashSet<PluralCategory> = HashSet::new(); |  | ||||||
|             for n in 1..60 { |  | ||||||
|                 plurals_m.insert(pr.select(n).unwrap().into()); |  | ||||||
|             } |  | ||||||
|             let mut plurals_s = plurals_m.clone(); |  | ||||||
| 
 |  | ||||||
|             for v in durations.values() { |  | ||||||
|                 let (m, s) = split_duration(*v); |  | ||||||
|                 plurals_m.remove(&pr.select(m).unwrap().into()); |  | ||||||
|                 plurals_s.remove(&pr.select(s).unwrap().into()); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             if !plurals_m.is_empty() { |  | ||||||
|                 println!("{lang}: missing minutes {plurals_m:?}"); |  | ||||||
|                 failed = true; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             if !plurals_s.is_empty() { |  | ||||||
|                 println!("{lang}: missing seconds {plurals_m:?}"); |  | ||||||
|                 failed = true; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         assert!(!failed); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -5,80 +5,71 @@ use std::{ | ||||||
|     sync::Mutex, |     sync::Mutex, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use path_macro::path; |  | ||||||
| use rustypipe::{ | use rustypipe::{ | ||||||
|     client::{ClientType, RustyPipe}, |     client::{ClientType, RustyPipe}, | ||||||
|     model::YouTubeItem, |  | ||||||
|     param::{ |     param::{ | ||||||
|         search_filter::{self, ItemType, SearchFilter}, |         search_filter::{self, ItemType, SearchFilter}, | ||||||
|         ChannelVideoTab, Country, |         Country, | ||||||
|     }, |     }, | ||||||
|     report::{Report, Reporter}, |     report::{Report, Reporter}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use crate::util::TESTFILES_DIR; | pub async fn download_testfiles(project_root: &Path) { | ||||||
|  |     let mut testfiles = project_root.to_path_buf(); | ||||||
|  |     testfiles.push("testfiles"); | ||||||
| 
 | 
 | ||||||
| pub async fn download_testfiles() { |     player(&testfiles).await; | ||||||
|     player().await; |     player_model(&testfiles).await; | ||||||
|     player_model().await; |     playlist(&testfiles).await; | ||||||
|     playlist().await; |     playlist_cont(&testfiles).await; | ||||||
|     playlist_cont().await; |     video_details(&testfiles).await; | ||||||
|     video_details().await; |     comments_top(&testfiles).await; | ||||||
|     comments_top().await; |     comments_latest(&testfiles).await; | ||||||
|     comments_latest().await; |     recommendations(&testfiles).await; | ||||||
|     recommendations().await; |     channel_videos(&testfiles).await; | ||||||
|     channel_videos().await; |     channel_shorts(&testfiles).await; | ||||||
|     channel_shorts().await; |     channel_livestreams(&testfiles).await; | ||||||
|     channel_livestreams().await; |     channel_playlists(&testfiles).await; | ||||||
|     channel_playlists().await; |     channel_info(&testfiles).await; | ||||||
|     channel_info().await; |     channel_videos_cont(&testfiles).await; | ||||||
|     channel_videos_cont().await; |     channel_playlists_cont(&testfiles).await; | ||||||
|     channel_playlists_cont().await; |     channel_tv(&testfiles).await; | ||||||
|     search().await; |     search(&testfiles).await; | ||||||
|     search_cont().await; |     search_cont(&testfiles).await; | ||||||
|     search_playlists().await; |     search_playlists(&testfiles).await; | ||||||
|     search_empty().await; |     search_empty(&testfiles).await; | ||||||
|     trending().await; |     startpage(&testfiles).await; | ||||||
|  |     startpage_cont(&testfiles).await; | ||||||
|  |     trending(&testfiles).await; | ||||||
| 
 | 
 | ||||||
|     music_playlist().await; |     music_playlist(&testfiles).await; | ||||||
|     music_playlist_cont().await; |     music_playlist_cont(&testfiles).await; | ||||||
|     music_playlist_related().await; |     music_playlist_related(&testfiles).await; | ||||||
|     music_album().await; |     music_album(&testfiles).await; | ||||||
|     music_search().await; |     music_search(&testfiles).await; | ||||||
|     music_search_tracks().await; |     music_search_tracks(&testfiles).await; | ||||||
|     music_search_albums().await; |     music_search_albums(&testfiles).await; | ||||||
|     music_search_artists().await; |     music_search_artists(&testfiles).await; | ||||||
|     music_search_playlists().await; |     music_search_playlists(&testfiles).await; | ||||||
|     music_search_cont().await; |     music_search_cont(&testfiles).await; | ||||||
|     music_search_suggestion().await; |     music_search_suggestion(&testfiles).await; | ||||||
|     music_artist().await; |     music_artist(&testfiles).await; | ||||||
|     music_details().await; |     music_details(&testfiles).await; | ||||||
|     music_lyrics().await; |     music_lyrics(&testfiles).await; | ||||||
|     music_related().await; |     music_related(&testfiles).await; | ||||||
|     music_radio().await; |     music_radio(&testfiles).await; | ||||||
|     music_radio_cont().await; |     music_radio_cont(&testfiles).await; | ||||||
|     music_new_albums().await; |     music_new_albums(&testfiles).await; | ||||||
|     music_new_videos().await; |     music_new_videos(&testfiles).await; | ||||||
|     music_charts().await; |     music_charts(&testfiles).await; | ||||||
|     music_genres().await; |     music_genres(&testfiles).await; | ||||||
|     music_genre().await; |     music_genre(&testfiles).await; | ||||||
| 
 |  | ||||||
|     // User data
 |  | ||||||
|     history().await; |  | ||||||
|     subscriptions().await; |  | ||||||
|     subscription_feed().await; |  | ||||||
| 
 |  | ||||||
|     music_history().await; |  | ||||||
|     music_saved_artists().await; |  | ||||||
|     music_saved_albums().await; |  | ||||||
|     music_saved_tracks().await; |  | ||||||
|     music_saved_playlists().await; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const CLIENT_TYPES: [ClientType; 5] = [ | const CLIENT_TYPES: [ClientType; 5] = [ | ||||||
|     ClientType::Desktop, |     ClientType::Desktop, | ||||||
|     ClientType::DesktopMusic, |     ClientType::DesktopMusic, | ||||||
|     ClientType::Tv, |     ClientType::TvHtml5Embed, | ||||||
|     ClientType::Android, |     ClientType::Android, | ||||||
|     ClientType::Ios, |     ClientType::Ios, | ||||||
| ]; | ]; | ||||||
|  | @ -144,15 +135,16 @@ fn rp_testfile(json_path: &Path) -> RustyPipe { | ||||||
|         .report() |         .report() | ||||||
|         .strict() |         .strict() | ||||||
|         .build() |         .build() | ||||||
|         .unwrap() |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn player() { | async fn player(testfiles: &Path) { | ||||||
|     let video_id = "pPvd8UxmSbQ"; |     let video_id = "pPvd8UxmSbQ"; | ||||||
| 
 | 
 | ||||||
|     for client_type in CLIENT_TYPES { |     for client_type in CLIENT_TYPES { | ||||||
|         let json_path = |         let mut json_path = testfiles.to_path_buf(); | ||||||
|             path!(*TESTFILES_DIR / "player" / format!("{client_type:?}_video.json").to_lowercase()); |         json_path.push("player"); | ||||||
|  |         json_path.push(format!("{client_type:?}_video.json").to_lowercase()); | ||||||
|  | 
 | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -165,12 +157,14 @@ async fn player() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn player_model() { | async fn player_model(testfiles: &Path) { | ||||||
|     let rp = RustyPipe::builder().strict().build().unwrap(); |     let rp = RustyPipe::builder().strict().build(); | ||||||
| 
 | 
 | ||||||
|     for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] { |     for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] { | ||||||
|         let json_path = |         let mut json_path = testfiles.to_path_buf(); | ||||||
|             path!(*TESTFILES_DIR / "player_model" / format!("{name}.json").to_lowercase()); |         json_path.push("player_model"); | ||||||
|  |         json_path.push(format!("{name}.json").to_lowercase()); | ||||||
|  | 
 | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -187,14 +181,15 @@ async fn player_model() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn playlist() { | async fn playlist(testfiles: &Path) { | ||||||
|     for (name, id) in [ |     for (name, id) in [ | ||||||
|         ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), |         ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), | ||||||
|         ("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"), |         ("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"), | ||||||
|         ("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"), |         ("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"), | ||||||
|         ("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw"), |  | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "playlist" / format!("playlist_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("playlist"); | ||||||
|  |         json_path.push(format!("playlist_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -204,8 +199,10 @@ async fn playlist() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn playlist_cont() { | async fn playlist_cont(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "playlist" / "playlist_cont.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("playlist"); | ||||||
|  |     json_path.push("playlist_cont.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -221,7 +218,7 @@ async fn playlist_cont() { | ||||||
|     playlist.videos.next(rp.query()).await.unwrap().unwrap(); |     playlist.videos.next(rp.query()).await.unwrap().unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn video_details() { | async fn video_details(testfiles: &Path) { | ||||||
|     for (name, id) in [ |     for (name, id) in [ | ||||||
|         ("music", "XuM2onMGvTI"), |         ("music", "XuM2onMGvTI"), | ||||||
|         ("mv", "ZeerrnuLi5E"), |         ("mv", "ZeerrnuLi5E"), | ||||||
|  | @ -230,8 +227,9 @@ async fn video_details() { | ||||||
|         ("live", "86YLFOog4GM"), |         ("live", "86YLFOog4GM"), | ||||||
|         ("agegate", "HRKu0cvrr_o"), |         ("agegate", "HRKu0cvrr_o"), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = |         let mut json_path = testfiles.to_path_buf(); | ||||||
|             path!(*TESTFILES_DIR / "video_details" / format!("video_details_{name}.json")); |         json_path.push("video_details"); | ||||||
|  |         json_path.push(format!("video_details_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -241,8 +239,10 @@ async fn video_details() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn comments_top() { | async fn comments_top(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "video_details" / "comments_top.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("video_details"); | ||||||
|  |     json_path.push("comments_top.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -259,8 +259,10 @@ async fn comments_top() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn comments_latest() { | async fn comments_latest(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "video_details" / "comments_latest.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("video_details"); | ||||||
|  |     json_path.push("comments_latest.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -277,8 +279,10 @@ async fn comments_latest() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn recommendations() { | async fn recommendations(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "video_details" / "recommendations.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("video_details"); | ||||||
|  |     json_path.push("recommendations.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -290,7 +294,7 @@ async fn recommendations() { | ||||||
|     details.recommended.next(rp.query()).await.unwrap(); |     details.recommended.next(rp.query()).await.unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn channel_videos() { | async fn channel_videos(testfiles: &Path) { | ||||||
|     for (name, id) in [ |     for (name, id) in [ | ||||||
|         ("base", "UC2DjFE7Xf11URZqWBigcVOQ"), |         ("base", "UC2DjFE7Xf11URZqWBigcVOQ"), | ||||||
|         ("music", "UC_vmjW5e1xEHhYjY2a0kK1A"), // YouTube Music channels have no videos
 |         ("music", "UC_vmjW5e1xEHhYjY2a0kK1A"), // YouTube Music channels have no videos
 | ||||||
|  | @ -299,7 +303,9 @@ async fn channel_videos() { | ||||||
|         ("empty", "UCxBa895m48H5idw5li7h-0g"), |         ("empty", "UCxBa895m48H5idw5li7h-0g"), | ||||||
|         ("upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A"), |         ("upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A"), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "channel" / format!("channel_videos_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("channel"); | ||||||
|  |         json_path.push(format!("channel_videos_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -309,34 +315,40 @@ async fn channel_videos() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn channel_shorts() { | async fn channel_shorts(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "channel" / "channel_shorts.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("channel"); | ||||||
|  |     json_path.push("channel_shorts.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let rp = rp_testfile(&json_path); |     let rp = rp_testfile(&json_path); | ||||||
|     rp.query() |     rp.query() | ||||||
|         .channel_videos_tab("UCh8gHdtzO2tXd593_bjErWg", ChannelVideoTab::Shorts) |         .channel_shorts("UCh8gHdtzO2tXd593_bjErWg") | ||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn channel_livestreams() { | async fn channel_livestreams(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "channel" / "channel_livestreams.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("channel"); | ||||||
|  |     json_path.push("channel_livestreams.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let rp = rp_testfile(&json_path); |     let rp = rp_testfile(&json_path); | ||||||
|     rp.query() |     rp.query() | ||||||
|         .channel_videos_tab("UC2DjFE7Xf11URZqWBigcVOQ", ChannelVideoTab::Live) |         .channel_livestreams("UC2DjFE7Xf11URZqWBigcVOQ") | ||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn channel_playlists() { | async fn channel_playlists(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "channel" / "channel_playlists.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("channel"); | ||||||
|  |     json_path.push("channel_playlists.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -348,8 +360,10 @@ async fn channel_playlists() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn channel_info() { | async fn channel_info(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "channel" / "channel_info.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("channel"); | ||||||
|  |     json_path.push("channel_info.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -361,8 +375,10 @@ async fn channel_info() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn channel_videos_cont() { | async fn channel_videos_cont(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "channel" / "channel_videos_cont.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("channel"); | ||||||
|  |     json_path.push("channel_videos_cont.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -378,8 +394,10 @@ async fn channel_videos_cont() { | ||||||
|     videos.content.next(rp.query()).await.unwrap().unwrap(); |     videos.content.next(rp.query()).await.unwrap().unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn channel_playlists_cont() { | async fn channel_playlists_cont(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "channel" / "channel_playlists_cont.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("channel"); | ||||||
|  |     json_path.push("channel_playlists_cont.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -395,58 +413,79 @@ async fn channel_playlists_cont() { | ||||||
|     playlists.content.next(rp.query()).await.unwrap().unwrap(); |     playlists.content.next(rp.query()).await.unwrap().unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn search() { | async fn channel_tv(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "search" / "default.json"); |     for (name, id) in [ | ||||||
|  |         ("base", "UCXuqSBlHAE6Xw-yeJA0Tunw"), | ||||||
|  |         ("music", "UC_vmjW5e1xEHhYjY2a0kK1A"), | ||||||
|  |         ("live", "UCSJ4gkVC6NrvII8umztf0Ow"), | ||||||
|  |         ("live_upcoming", "UCWxlUwW9BgGISaakjGM37aw"), | ||||||
|  |         ("onevideo", "UCAkeE1thnToEXZTao-CZkHw"), | ||||||
|  |     ] { | ||||||
|  |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("channel_tv"); | ||||||
|  |         json_path.push(format!("{name}.json")); | ||||||
|  |         if json_path.exists() { | ||||||
|  |             continue; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         let rp = rp_testfile(&json_path); | ||||||
|  |         rp.query().channel_tv(id).await.unwrap(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async fn search(testfiles: &Path) { | ||||||
|  |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("search"); | ||||||
|  |     json_path.push("default.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let rp = rp_testfile(&json_path); |     let rp = rp_testfile(&json_path); | ||||||
|     rp.query() |     rp.query().search("doobydoobap").await.unwrap(); | ||||||
|         .search::<YouTubeItem, _>("doobydoobap") |  | ||||||
|         .await |  | ||||||
|         .unwrap(); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn search_cont() { | async fn search_cont(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "search" / "cont.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("search"); | ||||||
|  |     json_path.push("cont.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let rp = RustyPipe::new(); |     let rp = RustyPipe::new(); | ||||||
|     let search = rp |     let search = rp.query().search("doobydoobap").await.unwrap(); | ||||||
|         .query() |  | ||||||
|         .search::<YouTubeItem, _>("doobydoobap") |  | ||||||
|         .await |  | ||||||
|         .unwrap(); |  | ||||||
| 
 | 
 | ||||||
|     let rp = rp_testfile(&json_path); |     let rp = rp_testfile(&json_path); | ||||||
|     search.items.next(rp.query()).await.unwrap().unwrap(); |     search.items.next(rp.query()).await.unwrap().unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn search_playlists() { | async fn search_playlists(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "search" / "playlists.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("search"); | ||||||
|  |     json_path.push("playlists.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let rp = rp_testfile(&json_path); |     let rp = rp_testfile(&json_path); | ||||||
|     rp.query() |     rp.query() | ||||||
|         .search_filter::<YouTubeItem, _>("pop", &SearchFilter::new().item_type(ItemType::Playlist)) |         .search_filter("pop", &SearchFilter::new().item_type(ItemType::Playlist)) | ||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn search_empty() { | async fn search_empty(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "search" / "empty.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("search"); | ||||||
|  |     json_path.push("empty.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let rp = rp_testfile(&json_path); |     let rp = rp_testfile(&json_path); | ||||||
|     rp.query() |     rp.query() | ||||||
|         .search_filter::<YouTubeItem, _>( |         .search_filter( | ||||||
|             "test", |             "test", | ||||||
|             &SearchFilter::new() |             &SearchFilter::new() | ||||||
|                 .feature(search_filter::Feature::IsLive) |                 .feature(search_filter::Feature::IsLive) | ||||||
|  | @ -456,8 +495,37 @@ async fn search_empty() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn trending() { | async fn startpage(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "trends" / "trending_videos.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("trends"); | ||||||
|  |     json_path.push("startpage.json"); | ||||||
|  |     if json_path.exists() { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let rp = rp_testfile(&json_path); | ||||||
|  |     rp.query().startpage().await.unwrap(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async fn startpage_cont(testfiles: &Path) { | ||||||
|  |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("trends"); | ||||||
|  |     json_path.push("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(testfiles: &Path) { | ||||||
|  |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("trends"); | ||||||
|  |     json_path.push("trending.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -466,43 +534,15 @@ async fn trending() { | ||||||
|     rp.query().trending().await.unwrap(); |     rp.query().trending().await.unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn history() { | async fn music_playlist(testfiles: &Path) { | ||||||
|     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 [ |     for (name, id) in [ | ||||||
|         ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), |         ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), | ||||||
|         ("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"), |         ("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"), | ||||||
|         ("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"), |         ("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_playlist" / format!("playlist_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_playlist"); | ||||||
|  |         json_path.push(format!("playlist_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -512,8 +552,10 @@ async fn music_playlist() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_playlist_cont() { | async fn music_playlist_cont(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_playlist" / "playlist_cont.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_playlist"); | ||||||
|  |     json_path.push("playlist_cont.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -529,8 +571,10 @@ async fn music_playlist_cont() { | ||||||
|     playlist.tracks.next(rp.query()).await.unwrap().unwrap(); |     playlist.tracks.next(rp.query()).await.unwrap().unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_playlist_related() { | async fn music_playlist_related(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_playlist" / "playlist_related.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_playlist"); | ||||||
|  |     json_path.push("playlist_related.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -551,7 +595,7 @@ async fn music_playlist_related() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_album() { | async fn music_album(testfiles: &Path) { | ||||||
|     for (name, id) in [ |     for (name, id) in [ | ||||||
|         ("one_artist", "MPREb_nlBWQROfvjo"), |         ("one_artist", "MPREb_nlBWQROfvjo"), | ||||||
|         ("various_artists", "MPREb_8QkDeEIawvX"), |         ("various_artists", "MPREb_8QkDeEIawvX"), | ||||||
|  | @ -559,7 +603,9 @@ async fn music_album() { | ||||||
|         ("description", "MPREb_PiyfuVl6aYd"), |         ("description", "MPREb_PiyfuVl6aYd"), | ||||||
|         ("unavailable", "MPREb_AzuWg8qAVVl"), |         ("unavailable", "MPREb_AzuWg8qAVVl"), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_playlist" / format!("album_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_playlist"); | ||||||
|  |         json_path.push(format!("album_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -569,24 +615,26 @@ async fn music_album() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_search() { | async fn music_search(testfiles: &Path) { | ||||||
|     for (name, query) in [ |     for (name, query) in [ | ||||||
|         ("default", "black mamba"), |         ("default", "black mamba"), | ||||||
|         ("typo", "liblingsmensch"), |         ("typo", "liblingsmensch"), | ||||||
|         ("radio", "pop radio"), |         ("radio", "pop radio"), | ||||||
|         ("artist", "taylor swift"), |         ("artist", "taylor swift"), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_search" / format!("main_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_search"); | ||||||
|  |         json_path.push(format!("main_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let rp = rp_testfile(&json_path); |         let rp = rp_testfile(&json_path); | ||||||
|         rp.query().music_search_main(query).await.unwrap(); |         rp.query().music_search(query).await.unwrap(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_search_tracks() { | async fn music_search_tracks(testfiles: &Path) { | ||||||
|     for (name, query, videos) in [ |     for (name, query, videos) in [ | ||||||
|         ("default", "black mamba", false), |         ("default", "black mamba", false), | ||||||
|         ("videos", "black mamba", true), |         ("videos", "black mamba", true), | ||||||
|  | @ -597,7 +645,9 @@ async fn music_search_tracks() { | ||||||
|             false, |             false, | ||||||
|         ), |         ), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_search" / format!("tracks_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_search"); | ||||||
|  |         json_path.push(format!("tracks_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -611,8 +661,10 @@ async fn music_search_tracks() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_search_albums() { | async fn music_search_albums(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_search" / "albums.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_search"); | ||||||
|  |     json_path.push("albums.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -621,8 +673,10 @@ async fn music_search_albums() { | ||||||
|     rp.query().music_search_albums("black mamba").await.unwrap(); |     rp.query().music_search_albums("black mamba").await.unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_search_artists() { | async fn music_search_artists(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_search" / "artists.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_search"); | ||||||
|  |     json_path.push("artists.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -634,23 +688,27 @@ async fn music_search_artists() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_search_playlists() { | async fn music_search_playlists(testfiles: &Path) { | ||||||
|     for (name, community) in [("ytm", false), ("community", true)] { |     for (name, community) in [("ytm", false), ("community", true)] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_search" / format!("playlists_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_search"); | ||||||
|  |         json_path.push(format!("playlists_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let rp = rp_testfile(&json_path); |         let rp = rp_testfile(&json_path); | ||||||
|         rp.query() |         rp.query() | ||||||
|             .music_search_playlists("pop", community) |             .music_search_playlists_filter("pop", community) | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_search_cont() { | async fn music_search_cont(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_search" / "tracks_cont.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_search"); | ||||||
|  |     json_path.push("tracks_cont.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -662,9 +720,11 @@ async fn music_search_cont() { | ||||||
|     res.items.next(rp.query()).await.unwrap().unwrap(); |     res.items.next(rp.query()).await.unwrap().unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_search_suggestion() { | async fn music_search_suggestion(testfiles: &Path) { | ||||||
|     for (name, query) in [("default", "t"), ("empty", "reujbhevmfndxnjrze")] { |     for (name, query) in [("default", "t"), ("empty", "reujbhevmfndxnjrze")] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_search" / format!("suggestion_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_search"); | ||||||
|  |         json_path.push(format!("suggestion_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -674,15 +734,18 @@ async fn music_search_suggestion() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_artist() { | async fn music_artist(testfiles: &Path) { | ||||||
|     for (name, id, all_albums) in [ |     for (name, id, all_albums) in [ | ||||||
|         ("default", "UClmXPfaYhXOYsNn_QUyheWQ", true), |         ("default", "UClmXPfaYhXOYsNn_QUyheWQ", true), | ||||||
|  |         ("no_more_albums", "UC_vmjW5e1xEHhYjY2a0kK1A", true), | ||||||
|         ("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw", true), |         ("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw", true), | ||||||
|         ("no_artist", "UCh8gHdtzO2tXd593_bjErWg", true), |         ("no_artist", "UCh8gHdtzO2tXd593_bjErWg", true), | ||||||
|         ("only_more_singles", "UC0aXrjVxG5pZr99v77wZdPQ", true), |         ("only_more_singles", "UC0aXrjVxG5pZr99v77wZdPQ", true), | ||||||
|         ("secondary_channel", "UCC9192yGQD25eBZgFZ84MPw", false), |         ("secondary_channel", "UCC9192yGQD25eBZgFZ84MPw", false), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_artist" / format!("artist_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_artist"); | ||||||
|  |         json_path.push(format!("artist_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -692,9 +755,11 @@ async fn music_artist() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_details() { | async fn music_details(testfiles: &Path) { | ||||||
|     for (name, id) in [("mv", "ZeerrnuLi5E"), ("track", "7nigXQS1Xb0")] { |     for (name, id) in [("mv", "ZeerrnuLi5E"), ("track", "7nigXQS1Xb0")] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_details" / format!("details_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_details"); | ||||||
|  |         json_path.push(format!("details_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -704,8 +769,10 @@ async fn music_details() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_lyrics() { | async fn music_lyrics(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_details" / "lyrics.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_details"); | ||||||
|  |     json_path.push("lyrics.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -720,8 +787,10 @@ async fn music_lyrics() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_related() { | async fn music_related(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_details" / "related.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_details"); | ||||||
|  |     json_path.push("related.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -736,9 +805,11 @@ async fn music_related() { | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_radio() { | async fn music_radio(testfiles: &Path) { | ||||||
|     for (name, id) in [("mv", "RDAMVMZeerrnuLi5E"), ("track", "RDAMVM7nigXQS1Xb0")] { |     for (name, id) in [("mv", "RDAMVMZeerrnuLi5E"), ("track", "RDAMVM7nigXQS1Xb0")] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_details" / format!("radio_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_details"); | ||||||
|  |         json_path.push(format!("radio_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -748,8 +819,10 @@ async fn music_radio() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_radio_cont() { | async fn music_radio_cont(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_details" / "radio_cont.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_details"); | ||||||
|  |     json_path.push("radio_cont.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -761,8 +834,10 @@ async fn music_radio_cont() { | ||||||
|     res.next(rp.query()).await.unwrap().unwrap(); |     res.next(rp.query()).await.unwrap().unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_new_albums() { | async fn music_new_albums(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_new" / "albums_default.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_new"); | ||||||
|  |     json_path.push("albums_default.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -771,8 +846,10 @@ async fn music_new_albums() { | ||||||
|     rp.query().music_new_albums().await.unwrap(); |     rp.query().music_new_albums().await.unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_new_videos() { | async fn music_new_videos(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_new" / "videos_default.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_new"); | ||||||
|  |     json_path.push("videos_default.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -781,9 +858,11 @@ async fn music_new_videos() { | ||||||
|     rp.query().music_new_videos().await.unwrap(); |     rp.query().music_new_videos().await.unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_charts() { | async fn music_charts(testfiles: &Path) { | ||||||
|     for (name, country) in [("global", Some(Country::Zz)), ("US", Some(Country::Us))] { |     for (name, country) in [("global", Some(Country::Zz)), ("US", Some(Country::Us))] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_charts" / format!("charts_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_charts"); | ||||||
|  |         json_path.push(&format!("charts_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -793,8 +872,10 @@ async fn music_charts() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_genres() { | async fn music_genres(testfiles: &Path) { | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_genres" / "genres.json"); |     let mut json_path = testfiles.to_path_buf(); | ||||||
|  |     json_path.push("music_genres"); | ||||||
|  |     json_path.push("genres.json"); | ||||||
|     if json_path.exists() { |     if json_path.exists() { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  | @ -803,12 +884,14 @@ async fn music_genres() { | ||||||
|     rp.query().music_genres().await.unwrap(); |     rp.query().music_genres().await.unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn music_genre() { | async fn music_genre(testfiles: &Path) { | ||||||
|     for (name, id) in [ |     for (name, id) in [ | ||||||
|         ("default", "ggMPOg1uX1lMbVZmbzl6NlJ3"), |         ("default", "ggMPOg1uX1lMbVZmbzl6NlJ3"), | ||||||
|         ("mood", "ggMPOg1uX1JOQWZFeDByc2Jm"), |         ("mood", "ggMPOg1uX1JOQWZFeDByc2Jm"), | ||||||
|     ] { |     ] { | ||||||
|         let json_path = path!(*TESTFILES_DIR / "music_genres" / format!("genre_{name}.json")); |         let mut json_path = testfiles.to_path_buf(); | ||||||
|  |         json_path.push("music_genres"); | ||||||
|  |         json_path.push(&format!("genre_{name}.json")); | ||||||
|         if json_path.exists() { |         if json_path.exists() { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
|  | @ -817,53 +900,3 @@ async fn music_genre() { | ||||||
|         rp.query().music_genre(id).await.unwrap(); |         rp.query().music_genre(id).await.unwrap(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 |  | ||||||
| async fn music_history() { |  | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_userdata" / "music_history.json"); |  | ||||||
|     if json_path.exists() { |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let rp = rp_testfile(&json_path); |  | ||||||
|     rp.query().music_history().await.unwrap(); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn music_saved_artists() { |  | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_artists.json"); |  | ||||||
|     if json_path.exists() { |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let rp = rp_testfile(&json_path); |  | ||||||
|     rp.query().music_saved_artists().await.unwrap(); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn music_saved_albums() { |  | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_albums.json"); |  | ||||||
|     if json_path.exists() { |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let rp = rp_testfile(&json_path); |  | ||||||
|     rp.query().music_saved_albums().await.unwrap(); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn music_saved_tracks() { |  | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_tracks.json"); |  | ||||||
|     if json_path.exists() { |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let rp = rp_testfile(&json_path); |  | ||||||
|     rp.query().music_saved_tracks().await.unwrap(); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn music_saved_playlists() { |  | ||||||
|     let json_path = path!(*TESTFILES_DIR / "music_userdata" / "saved_playlists.json"); |  | ||||||
|     if json_path.exists() { |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let rp = rp_testfile(&json_path); |  | ||||||
|     rp.query().music_saved_playlists().await.unwrap(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,16 +1,16 @@ | ||||||
| use std::fmt::Write; | use std::fmt::Write; | ||||||
|  | use std::path::Path; | ||||||
| 
 | 
 | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use path_macro::path; |  | ||||||
| use regex::Regex; | use regex::Regex; | ||||||
|  | use rustypipe::timeago::TimeUnit; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::util; | ||||||
|     model::TimeUnit, | 
 | ||||||
|     util::{self, SRC_DIR}, | const TARGET_PATH: &str = "src/util/dictionary.rs"; | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) { | fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) { | ||||||
|     static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w*)$").unwrap()); |     static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w?)$").unwrap()); | ||||||
|     match TU_PATTERN.captures(tu) { |     match TU_PATTERN.captures(tu) { | ||||||
|         Some(cap) => ( |         Some(cap) => ( | ||||||
|             cap.get(1).unwrap().as_str().parse().unwrap_or(1), |             cap.get(1).unwrap().as_str().parse().unwrap_or(1), | ||||||
|  | @ -22,8 +22,6 @@ fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) { | ||||||
|                 "W" => Some(TimeUnit::Week), |                 "W" => Some(TimeUnit::Week), | ||||||
|                 "M" => Some(TimeUnit::Month), |                 "M" => Some(TimeUnit::Month), | ||||||
|                 "Y" => Some(TimeUnit::Year), |                 "Y" => Some(TimeUnit::Year), | ||||||
|                 "Wl" => Some(TimeUnit::LastWeek), |  | ||||||
|                 "Wd" => Some(TimeUnit::LastWeekday), |  | ||||||
|                 "" => None, |                 "" => None, | ||||||
|                 _ => panic!("invalid time unit: {tu}"), |                 _ => panic!("invalid time unit: {tu}"), | ||||||
|             }, |             }, | ||||||
|  | @ -32,24 +30,36 @@ fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub fn generate_dictionary() { | fn parse_date_cmp(c: char) -> &'static str { | ||||||
|     let dict = util::read_dict(); |     match c { | ||||||
|  |         'Y' => "Y", | ||||||
|  |         'y' => "YShort", | ||||||
|  |         'M' => "M", | ||||||
|  |         'D' => "D", | ||||||
|  |         'H' => "Hr", | ||||||
|  |         'h' => "Hr12", | ||||||
|  |         'm' => "Mi", | ||||||
|  |         _ => panic!("invalid date cmp: {c}"), | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn generate_dictionary(project_root: &Path) { | ||||||
|  |     let dict = util::read_dict(project_root); | ||||||
| 
 | 
 | ||||||
|     let code_head = r#"// This file is automatically generated. DO NOT EDIT.
 |     let code_head = r#"// This file is automatically generated. DO NOT EDIT.
 | ||||||
| // See codegen/gen_dictionary.rs for the generation code.
 | // See codegen/gen_dictionary.rs for the generation code.
 | ||||||
| #![allow(clippy::unreadable_literal)] |  | ||||||
| 
 |  | ||||||
| //! The dictionary contains the information required to parse dates and numbers
 |  | ||||||
| //! in all supported languages.
 |  | ||||||
| 
 |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     model::AlbumType, |     model::AlbumType, | ||||||
|     param::Language, |     param::Language, | ||||||
|     util::timeago::{TaToken, TimeUnit}, |     timeago::{DateCmp, TaToken, TimeUnit}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /// Dictionary entry containing language-specific parsing information
 | /// The dictionary contains the information required to parse dates and numbers
 | ||||||
|  | /// in all supported languages.
 | ||||||
| pub(crate) struct Entry { | pub(crate) struct Entry { | ||||||
|  |     /// Should the language be parsed by character instead of by word?
 | ||||||
|  |     /// (e.g. Chinese/Japanese)
 | ||||||
|  |     pub by_char: bool, | ||||||
|     /// Tokens for parsing timeago strings.
 |     /// Tokens for parsing timeago strings.
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// Format: Parsed token -> \[Quantity\] Identifier
 |     /// Format: Parsed token -> \[Quantity\] Identifier
 | ||||||
|  | @ -57,13 +67,20 @@ pub(crate) struct Entry { | ||||||
|     /// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay),
 |     /// Identifiers: `Y`(ear), `M`(month), `W`(eek), `D`(ay),
 | ||||||
|     /// `h`(our), `m`(inute), `s`(econd)
 |     /// `h`(our), `m`(inute), `s`(econd)
 | ||||||
|     pub timeago_tokens: phf::Map<&'static str, TaToken>, |     pub timeago_tokens: phf::Map<&'static str, TaToken>, | ||||||
|     /// True if the month has to be parsed before the day
 |     /// Order in which to parse numeric date components.
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// Examples:
 |     /// Examples:
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// - 03.01.2020 => DMY => false
 |     /// - 03.01.2020 => `"DMY"`
 | ||||||
|     /// - 01/03/2020 => MDY => true
 |     /// - Jan 3, 2020 => `"DY"`
 | ||||||
|     pub month_before_day: bool, |     pub date_order: &'static [DateCmp], | ||||||
|  |     /// Order in which to parse datetimes.
 | ||||||
|  |     ///
 | ||||||
|  |     /// Examples:
 | ||||||
|  |     ///
 | ||||||
|  |     /// - 2023-04-14 15:00 => `[Y,M,D,Hr,Mi]`
 | ||||||
|  |     /// - 4/14/23, 3:00 PM => `[M,D,YShort,Hr12,Mi]`
 | ||||||
|  |     pub datetime_order: &'static [DateCmp], | ||||||
|     /// Tokens for parsing month names.
 |     /// Tokens for parsing month names.
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// Format: Parsed token -> Month number (starting from 1)
 |     /// Format: Parsed token -> Month number (starting from 1)
 | ||||||
|  | @ -78,20 +95,10 @@ pub(crate) struct Entry { | ||||||
|     ///
 |     ///
 | ||||||
|     /// Format: Parsed token -> decimal power
 |     /// Format: Parsed token -> decimal power
 | ||||||
|     pub number_tokens: phf::Map<&'static str, u8>, |     pub number_tokens: phf::Map<&'static str, u8>, | ||||||
|     /// Tokens for parsing number strings with no digits (e.g. "No videos")
 |  | ||||||
|     ///
 |  | ||||||
|     /// Format: Parsed token -> value
 |  | ||||||
|     pub number_nd_tokens: phf::Map<&'static str, u8>, |  | ||||||
|     /// Names of album types (Album, Single, ...)
 |     /// Names of album types (Album, Single, ...)
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// Format: Parsed text -> Album type
 |     /// Format: Parsed text -> Album type
 | ||||||
|     pub album_types: phf::Map<&'static str, AlbumType>, |     pub album_types: phf::Map<&'static str, AlbumType>, | ||||||
|     /// Channel name prefix on playlist pages (e.g. `by`)
 |  | ||||||
|     pub chan_prefix: &'static str, |  | ||||||
|     /// Channel name suffix on playlist pages
 |  | ||||||
|     pub chan_suffix: &'static str, |  | ||||||
|     /// "Other versions" title on album pages
 |  | ||||||
|     pub album_versions_title: &'static str, |  | ||||||
| } | } | ||||||
| "#;
 | "#;
 | ||||||
| 
 | 
 | ||||||
|  | @ -101,11 +108,11 @@ pub(crate) fn entry(lang: Language) -> Entry { | ||||||
|         "#
 |         "#
 | ||||||
|     .to_owned(); |     .to_owned(); | ||||||
| 
 | 
 | ||||||
|     for (lang, entry) in &dict { |     dict.iter().for_each(|(lang, entry)| { | ||||||
|         // Match selector
 |         // Match selector
 | ||||||
|         let mut selector = format!("Language::{lang:?}"); |         let mut selector = format!("Language::{lang:?}"); | ||||||
|         entry.equivalent.iter().for_each(|eq| { |         entry.equivalent.iter().for_each(|eq| { | ||||||
|             write!(selector, " | Language::{eq:?}").unwrap(); |             let _ = write!(selector, " | Language::{eq:?}"); | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         // Timeago tokens
 |         // Timeago tokens
 | ||||||
|  | @ -140,54 +147,47 @@ pub(crate) fn entry(lang: Language) -> Entry { | ||||||
|             }; |             }; | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|  |         // Date order
 | ||||||
|  |         let mut date_order = "&[".to_owned(); | ||||||
|  |         entry.date_order.chars().for_each(|c| { | ||||||
|  |             let _ = write!(date_order, "DateCmp::{}, ", parse_date_cmp(c)); | ||||||
|  |         }); | ||||||
|  |         date_order = date_order.trim_end_matches([' ', ',']).to_owned() + "]"; | ||||||
|  | 
 | ||||||
|  |         // Datetime order
 | ||||||
|  |         let mut datetime_order = "&[".to_owned(); | ||||||
|  |         entry.datetime_order.chars().for_each(|c| { | ||||||
|  |             let _ = write!(datetime_order, "DateCmp::{}, ", parse_date_cmp(c)); | ||||||
|  |         }); | ||||||
|  |         datetime_order = datetime_order.trim_end_matches([' ', ',']).to_owned() + "]"; | ||||||
|  | 
 | ||||||
|         // Number tokens
 |         // Number tokens
 | ||||||
|         let mut number_tokens = phf_codegen::Map::<&str>::new(); |         let mut number_tokens = phf_codegen::Map::<&str>::new(); | ||||||
|         entry.number_tokens.iter().for_each(|(txt, mag)| { |         entry.number_tokens.iter().for_each(|(txt, mag)| { | ||||||
|             number_tokens.entry(txt, &mag.to_string()); |             number_tokens.entry(txt, &mag.to_string()); | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         // Number nd tokens
 |  | ||||||
|         let mut number_nd_tokens = phf_codegen::Map::<&str>::new(); |  | ||||||
|         entry.number_nd_tokens.iter().for_each(|(txt, mag)| { |  | ||||||
|             number_nd_tokens.entry(txt, &mag.to_string()); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         // Album types
 |         // Album types
 | ||||||
|         let mut album_types = phf_codegen::Map::<&str>::new(); |         let mut album_types = phf_codegen::Map::<&str>::new(); | ||||||
|         entry.album_types.iter().for_each(|(txt, album_type)| { |         entry.album_types.iter().for_each(|(txt, album_type)| { | ||||||
|             album_types.entry(txt, &format!("AlbumType::{album_type:?}")); |             album_types.entry(txt, &format!("AlbumType::{album_type:?}")); | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         let code_ta_tokens = &ta_tokens |         let code_ta_tokens = &ta_tokens.build().to_string().replace('\n', "\n            "); | ||||||
|             .build() |         let code_ta_nd_tokens = &ta_nd_tokens.build().to_string().replace('\n', "\n            "); | ||||||
|             .to_string() |  | ||||||
|             .replace('\n', "\n            "); |  | ||||||
|         let code_ta_nd_tokens = &ta_nd_tokens |  | ||||||
|             .build() |  | ||||||
|             .to_string() |  | ||||||
|             .replace('\n', "\n            "); |  | ||||||
|         let code_months = &months.build().to_string().replace('\n', "\n            "); |         let code_months = &months.build().to_string().replace('\n', "\n            "); | ||||||
|         let code_number_tokens = &number_tokens |         let code_number_tokens = &number_tokens.build().to_string().replace('\n', "\n            "); | ||||||
|             .build() |         let code_album_types = &album_types.build().to_string().replace('\n', "\n            "); | ||||||
|             .to_string() |  | ||||||
|             .replace('\n', "\n            "); |  | ||||||
|         let code_number_nd_tokens = &number_nd_tokens |  | ||||||
|             .build() |  | ||||||
|             .to_string() |  | ||||||
|             .replace('\n', "\n            "); |  | ||||||
|         let code_album_types = &album_types |  | ||||||
|             .build() |  | ||||||
|             .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            album_versions_title: {:?},\n        }},\n        ", |         let _ = write!(code_timeago_tokens, "{} => Entry {{\n            by_char: {:?},\n            timeago_tokens: {},\n            date_order: {},\n            datetime_order: {},\n            months: {},\n            timeago_nd_tokens: {},\n            comma_decimal: {:?},\n            number_tokens: {},\n            album_types: {},\n        }},\n        ", | ||||||
|         selector, code_ta_tokens, entry.month_before_day, code_months, code_ta_nd_tokens, entry.comma_decimal, code_number_tokens, code_number_nd_tokens, code_album_types, entry.chan_prefix, entry.chan_suffix, entry.album_versions_title).unwrap(); |         selector, entry.by_char, code_ta_tokens, date_order, datetime_order, code_months, code_ta_nd_tokens, entry.comma_decimal, code_number_tokens, code_album_types); | ||||||
|     } |     }); | ||||||
| 
 | 
 | ||||||
|     code_timeago_tokens = code_timeago_tokens.trim_end().to_owned() + "\n    }\n}\n"; |     code_timeago_tokens = code_timeago_tokens.trim_end().to_owned() + "\n    }\n}\n"; | ||||||
| 
 | 
 | ||||||
|     let code = format!("{code_head}\n{code_timeago_tokens}"); |     let code = format!("{code_head}\n{code_timeago_tokens}"); | ||||||
| 
 | 
 | ||||||
|     let target_path = path!(*SRC_DIR / "util" / "dictionary.rs"); |     let mut target_path = project_root.to_path_buf(); | ||||||
|  |     target_path.push(TARGET_PATH); | ||||||
|     std::fs::write(target_path, code).unwrap(); |     std::fs::write(target_path, code).unwrap(); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,18 +1,14 @@ | ||||||
| use std::collections::BTreeMap; | use std::collections::BTreeMap; | ||||||
| use std::fmt::Write; | use std::fmt::Write; | ||||||
| use std::fs::File; | use std::path::Path; | ||||||
| use std::io::BufReader; |  | ||||||
| 
 | 
 | ||||||
| use path_macro::path; |  | ||||||
| use reqwest::header; | use reqwest::header; | ||||||
| use reqwest::Client; | use reqwest::Client; | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::serde_as; | use serde_with::serde_as; | ||||||
| use serde_with::VecSkipError; | use serde_with::VecSkipError; | ||||||
| 
 | 
 | ||||||
| use crate::model::Text; | use crate::util::Text; | ||||||
| use crate::util::DICT_DIR; |  | ||||||
| use crate::util::SRC_DIR; |  | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | @ -141,48 +137,47 @@ struct LanguageCountryCommand { | ||||||
|     hl: String, |     hl: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub async fn generate_locales() { | pub async fn generate_locales(project_root: &Path) { | ||||||
|     let (languages, countries) = get_locales().await; |     let (languages, countries) = get_locales().await; | ||||||
| 
 | 
 | ||||||
|     let json_path = path!(*DICT_DIR / "lang_names.json"); |  | ||||||
|     let json_file = File::open(json_path).unwrap(); |  | ||||||
|     let lang_names: BTreeMap<String, String> = |  | ||||||
|         serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
| 
 |  | ||||||
|     let code_head = r#"// This file is automatically generated. DO NOT EDIT.
 |     let code_head = r#"// This file is automatically generated. DO NOT EDIT.
 | ||||||
| 
 | 
 | ||||||
| //! Languages and countries
 | //! Languages and countries
 | ||||||
| 
 | 
 | ||||||
| use std::str::FromStr; | use std::{fmt::Display, str::FromStr}; | ||||||
| 
 | 
 | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 |  | ||||||
| use crate::error::Error; |  | ||||||
| "#;
 | "#;
 | ||||||
| 
 | 
 | ||||||
|     let code_foot = r#"impl FromStr for Language {
 |     let code_foot = r#"impl Display for Language {
 | ||||||
|     type Err = Error; |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
| 
 |         f.write_str( | ||||||
|     fn from_str(s: &str) -> Result<Self, Self::Err> { |             &serde_json::to_string(self).map_or("".to_owned(), |s| s[1..s.len() - 1].to_owned()), | ||||||
|         let mut sub = s; |         ) | ||||||
|         loop { |  | ||||||
|             if let Ok(v) = serde_plain::from_str(sub) { |  | ||||||
|                 return Ok(v); |  | ||||||
|             } |  | ||||||
|             match sub.rfind('-') { |  | ||||||
|                 Some(pos) => { |  | ||||||
|                     sub = &sub[..pos]; |  | ||||||
|                 } |  | ||||||
|                 None => return Err(Error::Other("could not parse language `{s}`".into())), |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| serde_plain::derive_display_from_serialize!(Language); | impl Display for Country { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         f.write_str( | ||||||
|  |             &serde_json::to_string(self).map_or("".to_owned(), |s| s[1..s.len() - 1].to_owned()), | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| serde_plain::derive_fromstr_from_deserialize!(Country, Error); | impl FromStr for Language { | ||||||
| serde_plain::derive_display_from_serialize!(Country); |     type Err = serde_json::Error; | ||||||
|  |     fn from_str(s: &str) -> Result<Self, Self::Err> { | ||||||
|  |         serde_json::from_str(&format!("\"{}\"", s)) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl FromStr for Country { | ||||||
|  |     type Err = serde_json::Error; | ||||||
|  |     fn from_str(s: &str) -> Result<Self, Self::Err> { | ||||||
|  |         serde_json::from_str(&format!("\"{}\"", s)) | ||||||
|  |     } | ||||||
|  | } | ||||||
| "#;
 | "#;
 | ||||||
| 
 | 
 | ||||||
|     let mut code_langs = r#"/// Available languages
 |     let mut code_langs = r#"/// Available languages
 | ||||||
|  | @ -202,20 +197,11 @@ pub enum Country { | ||||||
|     .to_owned(); |     .to_owned(); | ||||||
| 
 | 
 | ||||||
|     let mut code_lang_array = format!( |     let mut code_lang_array = format!( | ||||||
|         r#"/// Array of all available languages
 |         "/// Array of all available languages\npub const LANGUAGES: [Language; {}] = [\n", | ||||||
| /// The languages are sorted by their native names. This array can be used to display
 |  | ||||||
| /// a language selection or to get the language code from a language name using binary search.
 |  | ||||||
| pub const LANGUAGES: [Language; {}] = [ |  | ||||||
| "#,
 |  | ||||||
|         languages.len() |         languages.len() | ||||||
|     ); |     ); | ||||||
|     let mut code_country_array = format!( |     let mut code_country_array = format!( | ||||||
|         r#"/// Array of all available countries
 |         "/// Array of all available countries\npub const COUNTRIES: [Country; {}] = [\n", | ||||||
| ///
 |  | ||||||
| /// The countries are sorted by their english names. This array can be used to display
 |  | ||||||
| /// a country selection or to get the country code from a country name using binary search.
 |  | ||||||
| pub const COUNTRIES: [Country; {}] = [ |  | ||||||
| "#,
 |  | ||||||
|         countries.len() |         countries.len() | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|  | @ -236,82 +222,55 @@ pub const COUNTRIES: [Country; {}] = [ | ||||||
| "#
 | "#
 | ||||||
|     .to_owned(); |     .to_owned(); | ||||||
| 
 | 
 | ||||||
|     for (code, native_name) in &languages { |     languages.iter().for_each(|(c, n)| { | ||||||
|         let enum_name = code.split('-').fold(String::new(), |mut output, c| { |         let enum_name = c | ||||||
|             let _ = write!( |             .split('-') | ||||||
|                 output, |             .map(|c| { | ||||||
|  |                 format!( | ||||||
|                     "{}{}", |                     "{}{}", | ||||||
|                     c[0..1].to_owned().to_uppercase(), |                     c[0..1].to_owned().to_uppercase(), | ||||||
|                     c[1..].to_owned().to_lowercase() |                     c[1..].to_owned().to_lowercase() | ||||||
|             ); |                 ) | ||||||
|             output |             }) | ||||||
|         }); |             .collect::<String>(); | ||||||
| 
 |  | ||||||
|         let en_name = lang_names.get(code).expect(code); |  | ||||||
| 
 | 
 | ||||||
|         // Language enum
 |         // Language enum
 | ||||||
|         if en_name == native_name || code.starts_with("en") { |         write!(code_langs, "    /// {n}\n    ").unwrap(); | ||||||
|             write!(code_langs, "    /// {native_name}\n    ").unwrap(); |         if c.contains('-') { | ||||||
|         } else { |             write!(code_langs, "#[serde(rename = \"{c}\")]\n    ").unwrap(); | ||||||
|             write!(code_langs, "    /// {en_name} / {native_name}\n    ").unwrap(); |  | ||||||
|         } |  | ||||||
|         if code.contains('-') { |  | ||||||
|             write!(code_langs, "#[serde(rename = \"{code}\")]\n    ").unwrap(); |  | ||||||
|         } |         } | ||||||
|         code_langs += &enum_name; |         code_langs += &enum_name; | ||||||
|         code_langs += ",\n"; |         code_langs += ",\n"; | ||||||
| 
 | 
 | ||||||
|  |         // Language array
 | ||||||
|  |         writeln!(code_lang_array, "    Language::{enum_name},").unwrap(); | ||||||
|  | 
 | ||||||
|         // Language names
 |         // Language names
 | ||||||
|         writeln!( |         writeln!( | ||||||
|             code_lang_names, |             code_lang_names, | ||||||
|             "            Language::{enum_name} => \"{native_name}\"," |             "            Language::{enum_name} => \"{n}\"," | ||||||
|         ) |         ) | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
|     } |     }); | ||||||
|     code_langs += "}\n"; |     code_langs += "}\n"; | ||||||
| 
 | 
 | ||||||
|     // Language array
 |     countries.iter().for_each(|(c, n)| { | ||||||
|     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(); |         let enum_name = c[0..1].to_owned().to_uppercase() + &c[1..].to_owned().to_lowercase(); | ||||||
| 
 | 
 | ||||||
|         // Country enum
 |         // Country enum
 | ||||||
|         writeln!(code_countries, "    /// {n}").unwrap(); |         writeln!(code_countries, "    /// {n}").unwrap(); | ||||||
|         writeln!(code_countries, "    {enum_name},").unwrap(); |         writeln!(code_countries, "    {enum_name},").unwrap(); | ||||||
| 
 | 
 | ||||||
|  |         // Country array
 | ||||||
|  |         writeln!(code_country_array, "    Country::{enum_name},").unwrap(); | ||||||
|  | 
 | ||||||
|         // Country names
 |         // Country names
 | ||||||
|         writeln!( |         writeln!( | ||||||
|             code_country_names, |             code_country_names, | ||||||
|             "            Country::{enum_name} => \"{n}\"," |             "            Country::{enum_name} => \"{n}\"," | ||||||
|         ) |         ) | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
|     } |     }); | ||||||
| 
 |  | ||||||
|     // Country array
 |  | ||||||
|     let countries_by_name = countries |  | ||||||
|         .iter() |  | ||||||
|         .map(|(k, v)| (v, k)) |  | ||||||
|         .collect::<BTreeMap<_, _>>(); |  | ||||||
|     for c in countries_by_name.values() { |  | ||||||
|         let enum_name = c[0..1].to_owned().to_uppercase() + &c[1..].to_owned().to_lowercase(); |  | ||||||
|         writeln!(code_country_array, "    Country::{enum_name},").unwrap(); |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     // Add Country::Zz / Global
 |     // Add Country::Zz / Global
 | ||||||
|     code_countries += "    /// Global (can only be used for music charts)\n"; |     code_countries += "    /// Global (can only be used for music charts)\n"; | ||||||
|  | @ -329,7 +288,8 @@ pub const COUNTRIES: [Country; {}] = [ | ||||||
|         "{code_head}\n{code_langs}\n{code_countries}\n{code_lang_array}\n{code_country_array}\n{code_lang_names}\n{code_country_names}\n{code_foot}" |         "{code_head}\n{code_langs}\n{code_countries}\n{code_lang_array}\n{code_country_array}\n{code_lang_names}\n{code_country_names}\n{code_foot}" | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     let target_path = path!(*SRC_DIR / "param" / "locale.rs"); |     let mut target_path = project_root.to_path_buf(); | ||||||
|  |     target_path.push("src/param/locale.rs"); | ||||||
|     std::fs::write(target_path, code).unwrap(); |     std::fs::write(target_path, code).unwrap(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -339,7 +299,7 @@ async fn get_locales() -> (BTreeMap<String, String>, BTreeMap<String, String>) { | ||||||
|         .post("https://www.youtube.com/youtubei/v1/account/account_menu?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false") |         .post("https://www.youtube.com/youtubei/v1/account/account_menu?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false") | ||||||
|         .header(header::CONTENT_TYPE, "application/json") |         .header(header::CONTENT_TYPE, "application/json") | ||||||
|         .body( |         .body( | ||||||
|             r#"{"context":{"client":{"clientName":"WEB","clientVersion":"2.20220914.06.00","platform":"DESKTOP","originalUrl":"https://www.youtube.com/","hl":"en","gl":"US"},"request":{"internalExperimentFlags":[],"useSsl":true},"user":{"lockedSafetyMode":false}}}"# |             r##"{"context":{"client":{"clientName":"WEB","clientVersion":"2.20220914.06.00","platform":"DESKTOP","originalUrl":"https://www.youtube.com/","hl":"en","gl":"US"},"request":{"internalExperimentFlags":[],"useSsl":true},"user":{"lockedSafetyMode":false}}}"## | ||||||
|         ) |         ) | ||||||
|         .send().await |         .send().await | ||||||
|         .unwrap() |         .unwrap() | ||||||
|  | @ -398,8 +358,8 @@ fn map_language_section(section: &CompactLinkRendererWrap) -> BTreeMap<String, S | ||||||
|                     .actions[0] |                     .actions[0] | ||||||
|                     .select_language_command |                     .select_language_command | ||||||
|                     .hl |                     .hl | ||||||
|                     .clone(), |                     .to_owned(), | ||||||
|                 i.compact_link_renderer.title.text.clone(), |                 i.compact_link_renderer.title.text.to_owned(), | ||||||
|             ) |             ) | ||||||
|         }) |         }) | ||||||
|         .collect() |         .collect() | ||||||
|  |  | ||||||
|  | @ -1,26 +1,23 @@ | ||||||
| #![warn(clippy::todo)] |  | ||||||
| 
 |  | ||||||
| mod abtest; | mod abtest; | ||||||
| mod collect_album_types; | mod collect_album_types; | ||||||
| mod collect_album_versions_titles; | mod collect_datetimes; | ||||||
| mod collect_chan_prefixes; |  | ||||||
| mod collect_history_dates; |  | ||||||
| mod collect_large_numbers; | mod collect_large_numbers; | ||||||
| mod collect_playlist_dates; | mod collect_playlist_dates; | ||||||
| mod collect_video_dates; |  | ||||||
| mod collect_video_durations; |  | ||||||
| mod download_testfiles; | mod download_testfiles; | ||||||
| mod gen_dictionary; | mod gen_dictionary; | ||||||
| mod gen_locales; | mod gen_locales; | ||||||
| mod model; |  | ||||||
| mod util; | mod util; | ||||||
| 
 | 
 | ||||||
|  | use std::path::PathBuf; | ||||||
|  | 
 | ||||||
| use clap::{Parser, Subcommand}; | use clap::{Parser, Subcommand}; | ||||||
| 
 | 
 | ||||||
| #[derive(Parser)] | #[derive(Parser)] | ||||||
| struct Cli { | struct Cli { | ||||||
|     #[clap(subcommand)] |     #[clap(subcommand)] | ||||||
|     command: Commands, |     command: Commands, | ||||||
|  |     #[clap(short = 'd', default_value = "..")] | ||||||
|  |     project_root: PathBuf, | ||||||
|     #[clap(short, default_value = "8")] |     #[clap(short, default_value = "8")] | ||||||
|     concurrency: usize, |     concurrency: usize, | ||||||
| } | } | ||||||
|  | @ -30,19 +27,11 @@ enum Commands { | ||||||
|     CollectPlaylistDates, |     CollectPlaylistDates, | ||||||
|     CollectLargeNumbers, |     CollectLargeNumbers, | ||||||
|     CollectAlbumTypes, |     CollectAlbumTypes, | ||||||
|     CollectVideoDurations, |     CollectDatetimes, | ||||||
|     CollectVideoDates, |  | ||||||
|     CollectHistoryDates, |  | ||||||
|     CollectMusicHistoryDates, |  | ||||||
|     CollectChanPrefixes, |  | ||||||
|     CollectAlbumVersionsTitles, |  | ||||||
|     ParsePlaylistDates, |     ParsePlaylistDates, | ||||||
|     ParseHistoryDates, |  | ||||||
|     ParseLargeNumbers, |     ParseLargeNumbers, | ||||||
|     ParseAlbumTypes, |     ParseAlbumTypes, | ||||||
|     ParseVideoDurations, |     ParseDatetimes, | ||||||
|     ParseChanPrefixes, |  | ||||||
|     ParseAlbumVersionsTitles, |  | ||||||
|     GenLocales, |     GenLocales, | ||||||
|     GenDict, |     GenDict, | ||||||
|     DownloadTestfiles, |     DownloadTestfiles, | ||||||
|  | @ -56,43 +45,37 @@ enum Commands { | ||||||
| 
 | 
 | ||||||
| #[tokio::main] | #[tokio::main] | ||||||
| async fn main() { | async fn main() { | ||||||
|     tracing_subscriber::fmt::init(); |     env_logger::init(); | ||||||
|     let cli = Cli::parse(); |     let cli = Cli::parse(); | ||||||
| 
 | 
 | ||||||
|     match cli.command { |     match cli.command { | ||||||
|         Commands::CollectPlaylistDates => { |         Commands::CollectPlaylistDates => { | ||||||
|             collect_playlist_dates::collect_dates(cli.concurrency).await |             collect_playlist_dates::collect_dates(&cli.project_root, cli.concurrency).await; | ||||||
|         } |         } | ||||||
|         Commands::CollectLargeNumbers => { |         Commands::CollectLargeNumbers => { | ||||||
|             collect_large_numbers::collect_large_numbers(cli.concurrency).await |             collect_large_numbers::collect_large_numbers(&cli.project_root, cli.concurrency).await; | ||||||
|         } |         } | ||||||
|         Commands::CollectAlbumTypes => { |         Commands::CollectAlbumTypes => { | ||||||
|             collect_album_types::collect_album_types(cli.concurrency).await |             collect_album_types::collect_album_types(&cli.project_root, cli.concurrency).await; | ||||||
|         } |         } | ||||||
|         Commands::CollectVideoDurations => { |         Commands::CollectDatetimes => { | ||||||
|             collect_video_durations::collect_video_durations(cli.concurrency).await |             collect_datetimes::collect_datetimes(&cli.project_root, cli.concurrency).await; | ||||||
|         } |         } | ||||||
|         Commands::CollectVideoDates => { |         Commands::ParsePlaylistDates => { | ||||||
|             collect_video_dates::collect_video_dates(cli.concurrency).await |             collect_playlist_dates::write_samples_to_dict(&cli.project_root) | ||||||
|         } |         } | ||||||
|         Commands::CollectHistoryDates => collect_history_dates::collect_dates().await, |         Commands::ParseLargeNumbers => { | ||||||
|         Commands::CollectMusicHistoryDates => collect_history_dates::collect_dates_music().await, |             collect_large_numbers::write_samples_to_dict(&cli.project_root) | ||||||
|         Commands::CollectChanPrefixes => collect_chan_prefixes::collect_chan_prefixes().await, |  | ||||||
|         Commands::CollectAlbumVersionsTitles => { |  | ||||||
|             collect_album_versions_titles::collect_album_versions_titles().await |  | ||||||
|         } |         } | ||||||
|         Commands::ParsePlaylistDates => collect_playlist_dates::write_samples_to_dict(), |         Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(&cli.project_root), | ||||||
|         Commands::ParseHistoryDates => collect_history_dates::write_samples_to_dict(), |         Commands::ParseDatetimes => collect_datetimes::write_samples_to_dict(&cli.project_root), | ||||||
|         Commands::ParseLargeNumbers => collect_large_numbers::write_samples_to_dict(), |         Commands::GenLocales => { | ||||||
|         Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(), |             gen_locales::generate_locales(&cli.project_root).await; | ||||||
|         Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(), |         } | ||||||
|         Commands::ParseChanPrefixes => collect_chan_prefixes::write_samples_to_dict(), |         Commands::GenDict => gen_dictionary::generate_dictionary(&cli.project_root), | ||||||
|         Commands::ParseAlbumVersionsTitles => { |         Commands::DownloadTestfiles => { | ||||||
|             collect_album_versions_titles::write_samples_to_dict() |             download_testfiles::download_testfiles(&cli.project_root).await | ||||||
|         } |         } | ||||||
|         Commands::GenLocales => gen_locales::generate_locales().await, |  | ||||||
|         Commands::GenDict => gen_dictionary::generate_dictionary(), |  | ||||||
|         Commands::DownloadTestfiles => download_testfiles::download_testfiles().await, |  | ||||||
|         Commands::AbTest { id, n } => { |         Commands::AbTest { id, n } => { | ||||||
|             match id { |             match id { | ||||||
|                 Some(id) => { |                 Some(id) => { | ||||||
|  | @ -116,7 +99,7 @@ async fn main() { | ||||||
|                 } |                 } | ||||||
|                 None => { |                 None => { | ||||||
|                     let res = abtest::run_all_tests(n, cli.concurrency).await; |                     let res = abtest::run_all_tests(n, cli.concurrency).await; | ||||||
|                     println!("{}", serde_json::to_string_pretty(&res).unwrap()); |                     println!("{}", serde_json::to_string_pretty(&res).unwrap()) | ||||||
|                 } |                 } | ||||||
|             }; |             }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -1,333 +0,0 @@ | ||||||
| use std::collections::BTreeMap; |  | ||||||
| 
 |  | ||||||
| use ordered_hash_map::OrderedHashMap; |  | ||||||
| use rustypipe::{model::AlbumType, param::Language}; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use serde_with::{serde_as, DefaultOnError, VecSkipError}; |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Default, Serialize, Deserialize)] |  | ||||||
| #[serde(default)] |  | ||||||
| pub struct DictEntry { |  | ||||||
|     /// List of languages that should be treated equally (e.g. EnUs/EnGb/EnIn)
 |  | ||||||
|     pub equivalent: Vec<Language>, |  | ||||||
|     /// 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>, |  | ||||||
|     /// Order in which to parse numeric date components. Formatted as
 |  | ||||||
|     /// a string of date identifiers (Y, M, D).
 |  | ||||||
|     ///
 |  | ||||||
|     /// Examples:
 |  | ||||||
|     ///
 |  | ||||||
|     /// - 03.01.2020 => `"DMY"`
 |  | ||||||
|     /// - Jan 3, 2020 => `"DY"`
 |  | ||||||
|     pub date_order: String, |  | ||||||
|     /// Tokens for parsing month names.
 |  | ||||||
|     ///
 |  | ||||||
|     /// Format: Parsed token -> Month number (starting from 1)
 |  | ||||||
|     pub months: BTreeMap<String, u8>, |  | ||||||
|     /// Tokens for parsing date strings with no digits (e.g. Today, Tomorrow)
 |  | ||||||
|     ///
 |  | ||||||
|     /// Format: Parsed token -> \[Quantity\] Identifier
 |  | ||||||
|     pub timeago_nd_tokens: OrderedHashMap<String, String>, |  | ||||||
|     /// Are commas (instead of points) used as decimal separators?
 |  | ||||||
|     pub comma_decimal: bool, |  | ||||||
|     /// Tokens for parsing decimal prefixes (K, M, B, ...)
 |  | ||||||
|     ///
 |  | ||||||
|     /// Format: Parsed token -> decimal power
 |  | ||||||
|     pub number_tokens: BTreeMap<String, u8>, |  | ||||||
|     /// Tokens for parsing number strings with no digits (e.g. "No videos")
 |  | ||||||
|     ///
 |  | ||||||
|     /// Format: Parsed token -> value
 |  | ||||||
|     pub number_nd_tokens: BTreeMap<String, u8>, |  | ||||||
|     /// Names of album types (Album, Single, ...)
 |  | ||||||
|     ///
 |  | ||||||
|     /// 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, |  | ||||||
|     /// "Other versions" title on album pages
 |  | ||||||
|     pub album_versions_title: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Parsed TimeAgo string, contains amount and time unit.
 |  | ||||||
| ///
 |  | ||||||
| /// Example: "14 hours ago" => `TimeAgo {n: 14, unit: TimeUnit::Hour}`
 |  | ||||||
| #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] |  | ||||||
| pub struct TimeAgo { |  | ||||||
|     /// Number of time units
 |  | ||||||
|     pub n: u8, |  | ||||||
|     /// Time unit
 |  | ||||||
|     pub unit: TimeUnit, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl std::fmt::Display for TimeAgo { |  | ||||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |  | ||||||
|         if self.n > 1 { |  | ||||||
|             write!(f, "{}{}", self.n, self.unit.as_str()) |  | ||||||
|         } else { |  | ||||||
|             f.write_str(self.unit.as_str()) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Parsed time unit
 |  | ||||||
| #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] |  | ||||||
| #[serde(rename_all = "lowercase")] |  | ||||||
| pub enum TimeUnit { |  | ||||||
|     Second, |  | ||||||
|     Minute, |  | ||||||
|     Hour, |  | ||||||
|     Day, |  | ||||||
|     Week, |  | ||||||
|     Month, |  | ||||||
|     Year, |  | ||||||
|     LastWeek, |  | ||||||
|     LastWeekday, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl TimeUnit { |  | ||||||
|     pub fn as_str(&self) -> &str { |  | ||||||
|         match self { |  | ||||||
|             TimeUnit::Second => "s", |  | ||||||
|             TimeUnit::Minute => "m", |  | ||||||
|             TimeUnit::Hour => "h", |  | ||||||
|             TimeUnit::Day => "D", |  | ||||||
|             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 browse_id: &'a str, |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub params: Option<&'a str>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct QCont<'a> { |  | ||||||
|     pub continuation: &'a str, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| pub struct TextRuns { |  | ||||||
|     pub runs: Vec<Text>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| pub struct Text { |  | ||||||
|     #[serde(alias = "simpleText")] |  | ||||||
|     pub text: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Channel { |  | ||||||
|     pub contents: TwoColumnBrowseResults, |  | ||||||
|     pub header: ChannelHeader, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ChannelHeader { |  | ||||||
|     pub c4_tabbed_header_renderer: HeaderRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct HeaderRenderer { |  | ||||||
|     pub subscriber_count_text: Text, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct TwoColumnBrowseResults { |  | ||||||
|     pub two_column_browse_results_renderer: TabsRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct TabsRenderer { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub tabs: Vec<Tab<RichGrid>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContentsRenderer<T> { |  | ||||||
|     #[serde(alias = "tabs")] |  | ||||||
|     pub contents: Vec<T>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Tab<T> { |  | ||||||
|     pub tab_renderer: TabRenderer<T>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[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 rich_grid_renderer: RichGridRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RichGridRenderer { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub contents: Vec<RichItemRendererWrap>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub header: Option<RichGridHeader>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RichItemRendererWrap { |  | ||||||
|     pub rich_item_renderer: RichItemRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RichItemRenderer { |  | ||||||
|     pub content: VideoRendererWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoRendererWrap { |  | ||||||
|     pub video_renderer: VideoRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoRenderer { |  | ||||||
|     /// `24,194 views`
 |  | ||||||
|     pub view_count_text: Text, |  | ||||||
|     /// `19K views`
 |  | ||||||
|     pub short_view_count_text: Text, |  | ||||||
|     pub length_text: LengthText, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct LengthText { |  | ||||||
|     /// `18 minutes, 26 seconds`
 |  | ||||||
|     pub accessibility: Accessibility, |  | ||||||
|     /// `18:26`
 |  | ||||||
|     pub simple_text: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Accessibility { |  | ||||||
|     pub accessibility_data: AccessibilityData, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AccessibilityData { |  | ||||||
|     pub label: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RichGridHeader { |  | ||||||
|     pub feed_filter_chip_bar_renderer: ChipBar, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ChipBar { |  | ||||||
|     pub contents: Vec<Chip>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Chip { |  | ||||||
|     pub chip_cloud_chip_renderer: ChipRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ChipRenderer { |  | ||||||
|     pub navigation_endpoint: NavigationEndpoint, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct NavigationEndpoint { |  | ||||||
|     pub continuation_command: ContinuationCommand, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContinuationCommand { |  | ||||||
|     pub token: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContinuationResponse { |  | ||||||
|     pub on_response_received_actions: Vec<ContinuationAction>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContinuationAction { |  | ||||||
|     pub reload_continuation_items_command: ContinuationItemsWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContinuationItemsWrap { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub continuation_items: Vec<RichItemRendererWrap>, |  | ||||||
| } |  | ||||||
|  | @ -1,75 +1,92 @@ | ||||||
| use std::{collections::BTreeMap, fs::File, io::BufReader, path::PathBuf, str::FromStr}; | use std::{ | ||||||
|  |     collections::BTreeMap, | ||||||
|  |     fs::File, | ||||||
|  |     io::BufReader, | ||||||
|  |     path::{Path, PathBuf}, | ||||||
|  |     str::FromStr, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use path_macro::path; | use path_macro::path; | ||||||
| use regex::Regex; | use rustypipe::{model::AlbumType, param::Language}; | ||||||
| use rustypipe::param::Language; |  | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
| use crate::model::DictEntry; | static DICT_PATH: Lazy<PathBuf> = Lazy::new(|| path!("testfiles" / "dict" / "dictionary.json")); | ||||||
| 
 |  | ||||||
| /// Get the path of the `testfiles` directory
 |  | ||||||
| pub static TESTFILES_DIR: Lazy<PathBuf> = Lazy::new(|| { |  | ||||||
|     path!(env!("CARGO_MANIFEST_DIR") / ".." / "testfiles") |  | ||||||
|         .canonicalize() |  | ||||||
|         .unwrap() |  | ||||||
| }); |  | ||||||
| /// Get the path of the `dict` directory
 |  | ||||||
| pub static DICT_DIR: Lazy<PathBuf> = Lazy::new(|| path!(*TESTFILES_DIR / "dict")); |  | ||||||
| /// Get the path of the `src` directory
 |  | ||||||
| pub static SRC_DIR: Lazy<PathBuf> = Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / ".." / "src")); |  | ||||||
| 
 | 
 | ||||||
| type Dictionary = BTreeMap<Language, DictEntry>; | type Dictionary = BTreeMap<Language, DictEntry>; | ||||||
| type DictionaryOverride = BTreeMap<Language, DictOverrideEntry>; |  | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Default, Serialize, Deserialize)] | #[derive(Debug, Default, Serialize, Deserialize)] | ||||||
| #[serde(default)] | #[serde(default)] | ||||||
| struct DictOverrideEntry { | pub struct DictEntry { | ||||||
|     number_tokens: BTreeMap<String, Option<u8>>, |     /// List of languages that should be treated equally (e.g. EnUs/EnGb/EnIn)
 | ||||||
|     number_nd_tokens: BTreeMap<String, Option<u8>>, |     pub equivalent: Vec<Language>, | ||||||
|  |     /// Should the language be parsed by character instead of by word?
 | ||||||
|  |     /// (e.g. Chinese/Japanese)
 | ||||||
|  |     pub by_char: 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: BTreeMap<String, String>, | ||||||
|  |     /// Order in which to parse numeric date components. Formatted as
 | ||||||
|  |     /// a string of date identifiers (Y, M, D).
 | ||||||
|  |     ///
 | ||||||
|  |     /// Examples:
 | ||||||
|  |     ///
 | ||||||
|  |     /// - 03.01.2020 => `"DMY"`
 | ||||||
|  |     /// - Jan 3, 2020 => `"DY"`
 | ||||||
|  |     pub date_order: String, | ||||||
|  |     /// Order in which to parse datetimes. Formatted as a string of
 | ||||||
|  |     /// date/time identifiers (Y, y, M, D, H, h, m).
 | ||||||
|  |     ///
 | ||||||
|  |     /// Examples:
 | ||||||
|  |     ///
 | ||||||
|  |     /// - 2023-04-14 15:00 => `"YMDHm"`
 | ||||||
|  |     /// - 4/14/23, 3:00 PM => `"MDyhm"`
 | ||||||
|  |     pub datetime_order: String, | ||||||
|  |     /// Tokens for parsing month names.
 | ||||||
|  |     ///
 | ||||||
|  |     /// Format: Parsed token -> Month number (starting from 1)
 | ||||||
|  |     pub months: BTreeMap<String, u8>, | ||||||
|  |     /// Tokens for parsing date strings with no digits (e.g. Today, Tomorrow)
 | ||||||
|  |     ///
 | ||||||
|  |     /// Format: Parsed token -> \[Quantity\] Identifier
 | ||||||
|  |     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, ...)
 | ||||||
|  |     ///
 | ||||||
|  |     /// Format: Parsed token -> decimal power
 | ||||||
|  |     pub number_tokens: BTreeMap<String, u8>, | ||||||
|  |     /// Names of album types (Album, Single, ...)
 | ||||||
|  |     ///
 | ||||||
|  |     /// Format: Parsed text -> Album type
 | ||||||
|  |     pub album_types: BTreeMap<String, AlbumType>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub fn read_dict() -> Dictionary { | #[derive(Clone, Debug, Deserialize)] | ||||||
|     let json_path = path!(*DICT_DIR / "dictionary.json"); | pub struct TextRuns { | ||||||
|  |     pub runs: Vec<Text>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Deserialize)] | ||||||
|  | pub struct Text { | ||||||
|  |     #[serde(alias = "simpleText")] | ||||||
|  |     pub text: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn read_dict(project_root: &Path) -> Dictionary { | ||||||
|  |     let json_path = path!(project_root / *DICT_PATH); | ||||||
|     let json_file = File::open(json_path).unwrap(); |     let json_file = File::open(json_path).unwrap(); | ||||||
|     serde_json::from_reader(BufReader::new(json_file)).unwrap() |     serde_json::from_reader(BufReader::new(json_file)).unwrap() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn read_dict_override() -> DictionaryOverride { | pub fn write_dict(project_root: &Path, dict: &Dictionary) { | ||||||
|     let json_path = path!(*DICT_DIR / "dictionary_override.json"); |     let json_path = path!(project_root / *DICT_PATH); | ||||||
|     let json_file = File::open(json_path).unwrap(); |  | ||||||
|     serde_json::from_reader(BufReader::new(json_file)).unwrap() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| pub fn write_dict(dict: Dictionary) { |  | ||||||
|     let dict_override = read_dict_override(); |  | ||||||
| 
 |  | ||||||
|     let json_path = path!(*DICT_DIR / "dictionary.json"); |  | ||||||
|     let json_file = File::create(json_path).unwrap(); |     let json_file = File::create(json_path).unwrap(); | ||||||
| 
 |     serde_json::to_writer_pretty(json_file, dict).unwrap(); | ||||||
|     fn apply_map<K: Clone + Ord, V: Clone>(map: &mut BTreeMap<K, V>, or: &BTreeMap<K, Option<V>>) { |  | ||||||
|         or.iter().for_each(|(key, val)| match val { |  | ||||||
|             Some(val) => { |  | ||||||
|                 map.insert(key.clone(), val.clone()); |  | ||||||
|             } |  | ||||||
|             None => { |  | ||||||
|                 map.remove(key); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let dict: Dictionary = dict |  | ||||||
|         .into_iter() |  | ||||||
|         .map(|(lang, mut entry)| { |  | ||||||
|             if let Some(or) = dict_override.get(&lang) { |  | ||||||
|                 apply_map(&mut entry.number_tokens, &or.number_tokens); |  | ||||||
|                 apply_map(&mut entry.number_nd_tokens, &or.number_nd_tokens); |  | ||||||
|             } |  | ||||||
|             (lang, entry) |  | ||||||
|         }) |  | ||||||
|         .collect(); |  | ||||||
| 
 |  | ||||||
|     serde_json::to_writer_pretty(json_file, &dict).unwrap(); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub fn filter_datestr(string: &str) -> String { | pub fn filter_datestr(string: &str) -> String { | ||||||
|  | @ -77,7 +94,7 @@ pub fn filter_datestr(string: &str) -> String { | ||||||
|         .to_lowercase() |         .to_lowercase() | ||||||
|         .chars() |         .chars() | ||||||
|         .filter_map(|c| { |         .filter_map(|c| { | ||||||
|             if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() { |             if c == '\u{200b}' || c.is_ascii_digit() { | ||||||
|                 None |                 None | ||||||
|             } else if c == '-' { |             } else if c == '-' { | ||||||
|                 Some(' ') |                 Some(' ') | ||||||
|  | @ -91,20 +108,7 @@ pub fn filter_datestr(string: &str) -> String { | ||||||
| pub fn filter_largenumstr(string: &str) -> String { | pub fn filter_largenumstr(string: &str) -> String { | ||||||
|     string |     string | ||||||
|         .chars() |         .chars() | ||||||
|         .filter(|c| { |         .filter(|c| !matches!(c, '\u{200b}' | '.' | ',') && !c.is_ascii_digit()) | ||||||
|             !matches!( |  | ||||||
|                 c, |  | ||||||
|                 '\u{200b}' |  | ||||||
|                     | '\u{202b}' |  | ||||||
|                     | '\u{202c}' |  | ||||||
|                     | '\u{202e}' |  | ||||||
|                     | '\u{200e}' |  | ||||||
|                     | '\u{200f}' |  | ||||||
|                     | '.' |  | ||||||
|                     | ',' |  | ||||||
|             ) && !c.is_ascii_digit() |  | ||||||
|         }) |  | ||||||
|         .flat_map(char::to_lowercase) |  | ||||||
|         .collect() |         .collect() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -134,77 +138,13 @@ where | ||||||
|         if c.is_ascii_digit() { |         if c.is_ascii_digit() { | ||||||
|             buf.push(c); |             buf.push(c); | ||||||
|         } else if !buf.is_empty() { |         } else if !buf.is_empty() { | ||||||
|             if let Ok(n) = buf.parse::<F>() { |             buf.parse::<F>().map_or((), |n| numbers.push(n)); | ||||||
|                 numbers.push(n); |  | ||||||
|             } |  | ||||||
|             buf.clear(); |             buf.clear(); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     if !buf.is_empty() { |     if !buf.is_empty() { | ||||||
|         if let Ok(n) = buf.parse::<F>() { |         buf.parse::<F>().map_or((), |n| numbers.push(n)); | ||||||
|             numbers.push(n); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     numbers |     numbers | ||||||
| } | } | ||||||
| 
 |  | ||||||
| pub fn parse_largenum_en(string: &str) -> Option<u64> { |  | ||||||
|     let (num, mut exp, filtered) = { |  | ||||||
|         let mut buf = String::new(); |  | ||||||
|         let mut filtered = String::new(); |  | ||||||
|         let mut exp = 0; |  | ||||||
|         let mut after_point = false; |  | ||||||
|         for c in string.chars() { |  | ||||||
|             if c.is_ascii_digit() { |  | ||||||
|                 buf.push(c); |  | ||||||
| 
 |  | ||||||
|                 if after_point { |  | ||||||
|                     exp -= 1; |  | ||||||
|                 } |  | ||||||
|             } else if c == '.' { |  | ||||||
|                 after_point = true; |  | ||||||
|             } else if !matches!(c, '\u{200b}' | '.' | ',') { |  | ||||||
|                 filtered.push(c); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         (buf.parse::<u64>().ok()?, exp, filtered) |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     let lookup_token = |token: &str| match token { |  | ||||||
|         "K" => Some(3), |  | ||||||
|         "M" => Some(6), |  | ||||||
|         "B" => Some(9), |  | ||||||
|         _ => None, |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     exp += filtered |  | ||||||
|         .split_whitespace() |  | ||||||
|         .filter_map(lookup_token) |  | ||||||
|         .sum::<i32>(); |  | ||||||
| 
 |  | ||||||
|     num.checked_mul((10_u64).checked_pow(exp.try_into().ok()?)?) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Parse textual video length (e.g. `0:49`, `2:02` or `1:48:18`)
 |  | ||||||
| /// 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()); |  | ||||||
|     VIDEO_LENGTH_REGEX.captures(text).map(|cap| { |  | ||||||
|         let hrs = cap |  | ||||||
|             .get(1) |  | ||||||
|             .and_then(|x| x.as_str().parse::<u32>().ok()) |  | ||||||
|             .unwrap_or_default(); |  | ||||||
|         let min = cap |  | ||||||
|             .get(2) |  | ||||||
|             .and_then(|x| x.as_str().parse::<u32>().ok()) |  | ||||||
|             .unwrap_or_default(); |  | ||||||
|         let sec = cap |  | ||||||
|             .get(3) |  | ||||||
|             .and_then(|x| x.as_str().parse::<u32>().ok()) |  | ||||||
|             .unwrap_or_default(); |  | ||||||
| 
 |  | ||||||
|         hrs * 3600 + min * 60 + sec |  | ||||||
|     }) |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,175 +0,0 @@ | ||||||
| # Changelog |  | ||||||
| 
 |  | ||||||
| All notable changes to this project will be documented in this file. |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [v0.3.1](https://codeberg.org/ThetaDev/rustypipe/compare/rustypipe-downloader/v0.3.0..rustypipe-downloader/v0.3.1) - 2024-12-20 |  | ||||||
| 
 |  | ||||||
| ### ⚙️ Miscellaneous Tasks |  | ||||||
| 
 |  | ||||||
| - *(deps)* Update rustypipe to 0.11.0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## [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,26 +1,11 @@ | ||||||
| [package] | [package] | ||||||
| name = "rustypipe-downloader" | name = "rustypipe-downloader" | ||||||
| version = "0.3.1" | version = "0.1.0" | ||||||
| rust-version = "1.67.1" | edition = "2021" | ||||||
| edition.workspace = true |  | ||||||
| authors.workspace = true |  | ||||||
| license.workspace = true |  | ||||||
| repository.workspace = true |  | ||||||
| keywords.workspace = true |  | ||||||
| categories.workspace = true |  | ||||||
| description = "Downloader extension for RustyPipe" |  | ||||||
| 
 | 
 | ||||||
| [features] | [features] | ||||||
| default = ["default-tls"] | # Reqwest TLS | ||||||
| 
 |  | ||||||
| # Reqwest TLS options |  | ||||||
| default-tls = ["reqwest/default-tls", "rustypipe/default-tls"] | default-tls = ["reqwest/default-tls", "rustypipe/default-tls"] | ||||||
| native-tls = ["reqwest/native-tls", "rustypipe/native-tls"] |  | ||||||
| native-tls-alpn = ["reqwest/native-tls-alpn", "rustypipe/native-tls-alpn"] |  | ||||||
| native-tls-vendored = [ |  | ||||||
|     "reqwest/native-tls-vendored", |  | ||||||
|     "rustypipe/native-tls-vendored", |  | ||||||
| ] |  | ||||||
| rustls-tls-webpki-roots = [ | rustls-tls-webpki-roots = [ | ||||||
|     "reqwest/rustls-tls-webpki-roots", |     "reqwest/rustls-tls-webpki-roots", | ||||||
|     "rustypipe/rustls-tls-webpki-roots", |     "rustypipe/rustls-tls-webpki-roots", | ||||||
|  | @ -30,37 +15,17 @@ rustls-tls-native-roots = [ | ||||||
|     "rustypipe/rustls-tls-native-roots", |     "rustypipe/rustls-tls-native-roots", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| audiotag = ["dep:lofty", "dep:image", "dep:smartcrop2"] |  | ||||||
| 
 |  | ||||||
| [dependencies] | [dependencies] | ||||||
| rustypipe.workspace = true | rustypipe = { path = "..", default-features = false } | ||||||
| once_cell.workspace = true | once_cell = "1.12.0" | ||||||
| regex.workspace = true | regex = "1.6.0" | ||||||
| thiserror.workspace = true | thiserror = "1.0.36" | ||||||
| futures-util.workspace = true | futures = "0.3.21" | ||||||
| reqwest = { workspace = true, features = ["stream"] } | indicatif = "0.17.0" | ||||||
| rand.workspace = true | filenamify = "0.1.0" | ||||||
| tokio = { workspace = true, features = ["macros", "fs", "process"] } | log = "0.4.17" | ||||||
| indicatif = { workspace = true, optional = true } | reqwest = { version = "0.11.11", default-features = false, features = [ | ||||||
| filenamify.workspace = true |     "stream", | ||||||
| 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", |  | ||||||
| ] } | ] } | ||||||
| smartcrop2 = { version = "0.4.0", optional = true } | rand = "0.8.5" | ||||||
| 
 | tokio = { version = "1.20.0", features = ["macros", "fs", "process"] } | ||||||
| [dev-dependencies] |  | ||||||
| path_macro.workspace = true |  | ||||||
| rstest.workspace = true |  | ||||||
| serde_json.workspace = true |  | ||||||
| temp_testdir = "0.2.3" |  | ||||||
| 
 |  | ||||||
| [package.metadata.docs.rs] |  | ||||||
| # To build locally: |  | ||||||
| # RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --features indicatif,audiotag --no-deps --open |  | ||||||
| features = ["indicatif", "audiotag"] |  | ||||||
| rustdoc-args = ["--cfg", "docsrs"] |  | ||||||
|  |  | ||||||
|  | @ -1,47 +0,0 @@ | ||||||
| #  Downloader |  | ||||||
| 
 |  | ||||||
| [](https://crates.io/crates/rustypipe-downloader) |  | ||||||
| [](https://opensource.org/licenses/GPL-3.0) |  | ||||||
| [](https://docs.rs/rustypipe-downloader) |  | ||||||
| [](https://codeberg.org/ThetaDev/rustypipe/actions/?workflow=ci.yaml) |  | ||||||
| 
 |  | ||||||
| The downloader is a companion crate for RustyPipe that allows for easy and fast |  | ||||||
| downloading of video and audio files. |  | ||||||
| 
 |  | ||||||
| ## Features |  | ||||||
| 
 |  | ||||||
| - Fast download of streams, bypassing YouTube's throttling |  | ||||||
| - Join video and audio streams using ffmpeg |  | ||||||
| - [Indicatif](https://crates.io/crates/indicatif) support to show download progress bars |  | ||||||
|   (enable `indicatif` feature to use) |  | ||||||
| - Tag audio files with title, album, artist, date, description and album cover (enable |  | ||||||
|   `audiotag` feature to use) |  | ||||||
| - Album covers are automatically cropped using smartcrop to ensure they are square |  | ||||||
| 
 |  | ||||||
| ## How to use |  | ||||||
| 
 |  | ||||||
| For the downloader to work, you need to have ffmpeg installed on your system. If your |  | ||||||
| ffmpeg binary is located at a non-standard path, you can configure the location using |  | ||||||
| [`DownloaderBuilder::ffmpeg`]. |  | ||||||
| 
 |  | ||||||
| At first you have to instantiate and configure the downloader using either |  | ||||||
| [`Downloader::new`] or the [`DownloaderBuilder`]. |  | ||||||
| 
 |  | ||||||
| Then you can build a new download query with a video ID, stream filter and destination |  | ||||||
| path and finally download the video. |  | ||||||
| 
 |  | ||||||
| ```rust ignore |  | ||||||
| use rustypipe::param::StreamFilter; |  | ||||||
| use rustypipe_downloader::DownloaderBuilder; |  | ||||||
| 
 |  | ||||||
| let dl = DownloaderBuilder::new() |  | ||||||
|     .audio_tag() |  | ||||||
|     .crop_cover() |  | ||||||
|     .build(); |  | ||||||
| 
 |  | ||||||
| let filter_audio = StreamFilter::new().no_video(); |  | ||||||
| dl.id("eRsGyueVLvQ").stream_filter(filter_audio).to_file("audio.opus").download().await; |  | ||||||
| 
 |  | ||||||
| let filter_video = StreamFilter::new().video_max_res(720); |  | ||||||
| dl.id("eRsGyueVLvQ").stream_filter(filter_video).to_file("video.mp4").download().await; |  | ||||||
| ``` |  | ||||||
|  | @ -1,59 +0,0 @@ | ||||||
| use std::{borrow::Cow, path::PathBuf}; |  | ||||||
| 
 |  | ||||||
| use rustypipe::client::ClientType; |  | ||||||
| 
 |  | ||||||
| /// Error from the video downloader
 |  | ||||||
| #[derive(thiserror::Error, Debug)] |  | ||||||
| #[non_exhaustive] |  | ||||||
| pub enum DownloadError { |  | ||||||
|     /// RustyPipe error
 |  | ||||||
|     #[error("{0}")] |  | ||||||
|     RustyPipe(#[from] rustypipe::error::Error), |  | ||||||
|     /// Error from the HTTP client
 |  | ||||||
|     #[error("http error: {0}")] |  | ||||||
|     Http(#[from] reqwest::Error), |  | ||||||
|     /// 403 error trying to download video
 |  | ||||||
|     #[error("YouTube returned 403 error; visitor_data={}", .visitor_data.as_deref().unwrap_or_default())] |  | ||||||
|     Forbidden { |  | ||||||
|         /// Client type used to fetch the failed stream
 |  | ||||||
|         client_type: ClientType, |  | ||||||
|         /// Visitor data used to fetch the failed stream
 |  | ||||||
|         visitor_data: Option<String>, |  | ||||||
|     }, |  | ||||||
|     /// File IO error
 |  | ||||||
|     #[error(transparent)] |  | ||||||
|     Io(#[from] std::io::Error), |  | ||||||
|     /// FFmpeg returned an error
 |  | ||||||
|     #[error("FFmpeg error: {0}")] |  | ||||||
|     Ffmpeg(Cow<'static, str>), |  | ||||||
|     /// Error parsing ranges for progressive download
 |  | ||||||
|     #[error("Progressive download error: {0}")] |  | ||||||
|     Progressive(Cow<'static, str>), |  | ||||||
|     /// Video could not be downloaded because of invalid player data
 |  | ||||||
|     #[error("source error: {0}")] |  | ||||||
|     Source(Cow<'static, str>), |  | ||||||
|     /// Download target already exists
 |  | ||||||
|     #[error("file {0} already exists")] |  | ||||||
|     Exists(PathBuf), |  | ||||||
|     #[cfg(feature = "audiotag")] |  | ||||||
|     /// Audio tagging error
 |  | ||||||
|     #[error("Audio tag error: {0}")] |  | ||||||
|     AudioTag(Cow<'static, str>), |  | ||||||
|     /// Other error
 |  | ||||||
|     #[error("error: {0}")] |  | ||||||
|     Other(Cow<'static, str>), |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(feature = "audiotag")] |  | ||||||
| impl From<lofty::error::LoftyError> for DownloadError { |  | ||||||
|     fn from(value: lofty::error::LoftyError) -> Self { |  | ||||||
|         Self::AudioTag(value.to_string().into()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(feature = "audiotag")] |  | ||||||
| impl From<image::ImageError> for DownloadError { |  | ||||||
|     fn from(value: image::ImageError) -> Self { |  | ||||||
|         Self::AudioTag(value.to_string().into()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,8 +1,26 @@ | ||||||
| use std::collections::BTreeMap; | use std::{borrow::Cow, collections::BTreeMap}; | ||||||
| 
 | 
 | ||||||
| use reqwest::Url; | use reqwest::Url; | ||||||
| 
 | 
 | ||||||
| use crate::DownloadError; | /// Error from the video downloader
 | ||||||
|  | #[derive(thiserror::Error, Debug)] | ||||||
|  | #[non_exhaustive] | ||||||
|  | pub enum DownloadError { | ||||||
|  |     /// Error from the HTTP client
 | ||||||
|  |     #[error("http error: {0}")] | ||||||
|  |     Http(#[from] reqwest::Error), | ||||||
|  |     /// File IO error
 | ||||||
|  |     #[error(transparent)] | ||||||
|  |     Io(#[from] std::io::Error), | ||||||
|  |     #[error("FFmpeg error: {0}")] | ||||||
|  |     Ffmpeg(Cow<'static, str>), | ||||||
|  |     #[error("Progressive download error: {0}")] | ||||||
|  |     Progressive(Cow<'static, str>), | ||||||
|  |     #[error("input error: {0}")] | ||||||
|  |     Input(Cow<'static, str>), | ||||||
|  |     #[error("error: {0}")] | ||||||
|  |     Other(Cow<'static, str>), | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| /// Split an URL into its base string and parameter map
 | /// Split an URL into its base string and parameter map
 | ||||||
| ///
 | ///
 | ||||||
|  |  | ||||||
|  | @ -1,127 +0,0 @@ | ||||||
| use std::{fs, os::unix::fs::MetadataExt, path::Path, process::Command}; |  | ||||||
| 
 |  | ||||||
| use path_macro::path; |  | ||||||
| use rstest::{fixture, rstest}; |  | ||||||
| use rustypipe::{client::RustyPipe, model::AudioCodec, param::StreamFilter}; |  | ||||||
| use rustypipe_downloader::Downloader; |  | ||||||
| use temp_testdir::TempDir; |  | ||||||
| 
 |  | ||||||
| /// Get a new RusttyPipe instance
 |  | ||||||
| #[fixture] |  | ||||||
| fn rp() -> RustyPipe { |  | ||||||
|     let vdata = std::env::var("YT_VDATA").ok(); |  | ||||||
|     RustyPipe::builder() |  | ||||||
|         .strict() |  | ||||||
|         .storage_dir(path!(env!("CARGO_MANIFEST_DIR") / "..")) |  | ||||||
|         .visitor_data_opt(vdata) |  | ||||||
|         .build() |  | ||||||
|         .unwrap() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[rstest] |  | ||||||
| #[tokio::test] |  | ||||||
| async fn download_video(rp: RustyPipe) { |  | ||||||
|     let td = TempDir::default(); |  | ||||||
|     let td_path = td.to_path_buf(); |  | ||||||
| 
 |  | ||||||
|     let dl = Downloader::builder().rustypipe(&rp).build(); |  | ||||||
| 
 |  | ||||||
|     let res = dl |  | ||||||
|         .id("UXqq0ZvbOnk") |  | ||||||
|         .to_dir(&td_path) |  | ||||||
|         .stream_filter(StreamFilter::new().video_max_res(480)) |  | ||||||
|         .download() |  | ||||||
|         .await |  | ||||||
|         .unwrap(); |  | ||||||
| 
 |  | ||||||
|     assert_eq!( |  | ||||||
|         res.dest, |  | ||||||
|         path!(td_path / "CHARGE - Blender Open Movie [UXqq0ZvbOnk].mp4") |  | ||||||
|     ); |  | ||||||
|     assert_eq!(res.player_data.details.id, "UXqq0ZvbOnk"); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[rstest] |  | ||||||
| #[tokio::test] |  | ||||||
| async fn download_music(rp: RustyPipe) { |  | ||||||
|     let td = TempDir::default(); |  | ||||||
|     let td_path = td.to_path_buf(); |  | ||||||
| 
 |  | ||||||
|     #[allow(unused_mut)] |  | ||||||
|     let mut dl = Downloader::builder().rustypipe(&rp); |  | ||||||
|     #[cfg(feature = "audiotag")] |  | ||||||
|     { |  | ||||||
|         dl = dl.audio_tag().crop_cover(); |  | ||||||
|     } |  | ||||||
|     let dl = dl.build(); |  | ||||||
| 
 |  | ||||||
|     let res = dl |  | ||||||
|         .id("bVtv3st8bgc") |  | ||||||
|         .to_dir(&td_path) |  | ||||||
|         .stream_filter( |  | ||||||
|             StreamFilter::new() |  | ||||||
|                 .no_video() |  | ||||||
|                 .audio_codecs([AudioCodec::Opus]), |  | ||||||
|         ) |  | ||||||
|         .download() |  | ||||||
|         .await |  | ||||||
|         .unwrap(); |  | ||||||
| 
 |  | ||||||
|     assert_eq!( |  | ||||||
|         res.dest, |  | ||||||
|         path!(td_path / "Lord of the Riffs [bVtv3st8bgc].opus") |  | ||||||
|     ); |  | ||||||
|     assert_eq!(res.player_data.details.id, "bVtv3st8bgc"); |  | ||||||
|     let fm = fs::metadata(&res.dest).unwrap(); |  | ||||||
|     assert_gte(fm.size(), 6_000_000, "file size"); |  | ||||||
|     assert_audio_meta( |  | ||||||
|         &res.dest, |  | ||||||
|         "Lord of the Riffs", |  | ||||||
|         "Alexander Nakarada", |  | ||||||
|         "Lord of the Riffs", |  | ||||||
|         "2022-02-05", |  | ||||||
|     ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Assert that number A is greater than or equal to number B
 |  | ||||||
| #[track_caller] |  | ||||||
| fn assert_gte<T: PartialOrd + std::fmt::Display>(a: T, b: T, msg: &str) { |  | ||||||
|     assert!(a >= b, "expected >= {b} {msg}, got {a}"); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[track_caller] |  | ||||||
| fn assert_audio_meta(p: &Path, title: &str, artist: &str, album: &str, date: &str) { |  | ||||||
|     let res = Command::new("ffprobe") |  | ||||||
|         .args([ |  | ||||||
|             "-loglevel", |  | ||||||
|             "error", |  | ||||||
|             "-show_entries", |  | ||||||
|             "stream_tags", |  | ||||||
|             "-of", |  | ||||||
|             "json", |  | ||||||
|         ]) |  | ||||||
|         .arg(p) |  | ||||||
|         .output() |  | ||||||
|         .unwrap(); |  | ||||||
|     if !res.status.success() { |  | ||||||
|         panic!("ffprobe error\n{}", String::from_utf8_lossy(&res.stderr)) |  | ||||||
|     } |  | ||||||
|     let res_json = serde_json::from_slice::<serde_json::Value>(&res.stdout).unwrap(); |  | ||||||
|     let tags = &res_json["streams"][0]["tags"]; |  | ||||||
|     assert_eq!(tags["TITLE"].as_str(), Some(title)); |  | ||||||
|     assert_eq!(tags["ARTIST"].as_str(), Some(artist)); |  | ||||||
|     assert_eq!(tags["ALBUM"].as_str(), Some(album)); |  | ||||||
|     assert_eq!(tags["DATE"].as_str(), Some(date)); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// This is just a static check to make sure all RustyPipe futures can be sent
 |  | ||||||
| /// between threads safely.
 |  | ||||||
| /// Otherwise this may cause issues when integrating RustyPipe into async projects.
 |  | ||||||
| #[allow(unused)] |  | ||||||
| async fn all_send_and_sync() { |  | ||||||
|     fn send_and_sync<T: Send + Sync>(t: T) {} |  | ||||||
| 
 |  | ||||||
|     let dl = Downloader::default(); |  | ||||||
|     let dlq = dl.id(""); |  | ||||||
|     send_and_sync(dlq.download()); |  | ||||||
| } |  | ||||||
|  | @ -3,59 +3,47 @@ | ||||||
| When YouTube introduces a new feature, it does so gradually. When a user creates a new | When YouTube introduces a new feature, it does so gradually. When a user creates a new | ||||||
| session, YouTube decided randomly which new features should be enabled. | session, YouTube decided randomly which new features should be enabled. | ||||||
| 
 | 
 | ||||||
| YouTube sessions are identified by the visitor data ID. This cookie is sent with every | 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 | API request using the `context.client.visitor_data` JSON parameter. It is also returned in the | ||||||
| in the `responseContext.visitorData` response parameter and stored as the `__SECURE-YEC` | `responseContext.visitorData` response parameter and stored as the `__SECURE-YEC` cookie. | ||||||
| cookie. |  | ||||||
| 
 | 
 | ||||||
| By sending the same visitor data ID, A/B tests can be reproduced, which is important for | By sending the same visitor data cookie, A/B tests can be reproduced, which is important for testing | ||||||
| testing alternative YouTube clients. | alternative YouTube clients. | ||||||
| 
 | 
 | ||||||
| This page lists all A/B tests that were encountered while maintaining the RustyPipe | This page lists all A/B tests that were encountered while maintaining the RustyPipe client. | ||||||
| client. |  | ||||||
| 
 | 
 | ||||||
| **Impact rating:** | **Impact rating:** | ||||||
| 
 | 
 | ||||||
| The impact ratings shows how much effort it takes to adapt alternative YouTube clients | The impact ratings shows how much effort it takes to adapt alternative YouTube clients to the | ||||||
| to the new feature. | new feature. | ||||||
| 
 | 
 | ||||||
| - 🟢 **Low** Minor incompatibility (e.g. parameter name change) | - 🟢 **Low** Minor incompatibility (e.g. parameter name change) | ||||||
| - 🟡 **Medium** Extensive changes to the response data model OR removal of parameters | - 🟡 **Medium** Extensive changes to the response data model OR removal of parameters | ||||||
| - 🔴 **High** Changes to the functionality of YouTube that will require API changes for | - 🔴 **High** Changes to the functionality of YouTube that will require API changes | ||||||
|   alternative clients |   for alternative clients | ||||||
| 
 | 
 | ||||||
| **Status:** | 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>`. | ||||||
| - 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>`. |  | ||||||
| 
 | 
 | ||||||
| ## [1] Attributed text description | ## [1] Attributed text description | ||||||
| 
 | 
 | ||||||
| - **Encountered on:** 24.09.2022 | - **Encountered on:** 24.09.2022 | ||||||
| - **Impact:** 🟡 Medium | - **Impact:** 🟡 Medium | ||||||
| - **Endpoint:** next (video details) | - **Endpoint:** next (video details) | ||||||
| - **Status:** Stabilized |  | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
| 
 | 
 | ||||||
| YouTube shows internal links (channels, videos, playlists) in the video description as | YouTube shows internal links (channels, videos, playlists) in the video description | ||||||
| buttons with the YouTube icon. To accomplish this, they completely changed the | as buttons with the YouTube icon. To accomplish this, they completely changed the underlying | ||||||
| underlying data model. | data model. | ||||||
| 
 | 
 | ||||||
| The new format uses a string with the entire plaintext content along with a list of | The new format uses a string with the entire plaintext content along with a list of `"commandRuns"` | ||||||
| `"commandRuns"` which include the link data and the position of the links within the | which include the link data and the position of the links within the text. | ||||||
| text. |  | ||||||
| 
 | 
 | ||||||
| Note that the position and length parameter refer to the number of UTF-16 characters. If | Note that the position and length parameter refer to the number of UTF-16 characters. If | ||||||
| you are implementing this in a language which does not use UTF-16 as its internal string | you are implementing this in a language which does not use UTF-16 as its internal string | ||||||
| representation, you have to iterate over the unicode codepoints and keep track of the | representation, you have to iterate over the unicode codepoints and keep track of the UTF-16 | ||||||
| UTF-16 index seperately. | index seperately. | ||||||
| 
 | 
 | ||||||
| **OLD** | **OLD** | ||||||
| 
 | 
 | ||||||
|  | @ -130,22 +118,20 @@ UTF-16 index seperately. | ||||||
| - **Encountered on:** 11.10.2022 | - **Encountered on:** 11.10.2022 | ||||||
| - **Impact:** 🔴 High | - **Impact:** 🔴 High | ||||||
| - **Endpoint:** browse (channel videos) | - **Endpoint:** browse (channel videos) | ||||||
| - **Status:** Stabilized |  | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
| 
 | 
 | ||||||
| YouTube changed their channel page layout, putting livestreams and short videos into | YouTube changed their channel page layout, putting livestreams and short videos into | ||||||
| separate tabs. | separate tabs. | ||||||
| 
 | 
 | ||||||
| Fetching the videos page now only returns a subset of a channel's videos. To get all | Fetching the videos page now only returns a subset of a channel's videos. To get all videos | ||||||
| videos from a channel, you would have to run up to 3 queries. | from a channel, you would have to run up to 3 queries. | ||||||
| 
 | 
 | ||||||
| Even though it has its disadvantages, the RSS feed is now probably the best way for | Even though it has its disadvantages, the RSS feed is now probably the best way for keeping | ||||||
| keeping track of a channel's new uploads. | track of a channel's new uploads. | ||||||
| 
 | 
 | ||||||
| Additionally the channel tab response model was slightly changed, now using a | Additionally the channel tab response model was slightly changed, now using a `"RichGridRenderer"`. | ||||||
| `"RichGridRenderer"`. Short videos also have their own data models | Short videos also have their own data models (`"reelItemRenderer"`). | ||||||
| (`"reelItemRenderer"`). |  | ||||||
| 
 | 
 | ||||||
| **RichGrid** | **RichGrid** | ||||||
| 
 | 
 | ||||||
|  | @ -227,7 +213,6 @@ Additionally the channel tab response model was slightly changed, now using a | ||||||
| - **Encountered on:** 20.11.2022 | - **Encountered on:** 20.11.2022 | ||||||
| - **Impact:** 🟡 Medium | - **Impact:** 🟡 Medium | ||||||
| - **Endpoint:** search | - **Endpoint:** search | ||||||
| - **Status:** Stabilized |  | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
| 
 | 
 | ||||||
|  | @ -287,10 +272,9 @@ Note that channels without handles still use the old data model, even on the sam | ||||||
| - **Encountered on:** 1.04.2023 | - **Encountered on:** 1.04.2023 | ||||||
| - **Impact:** 🟢 Low | - **Impact:** 🟢 Low | ||||||
| - **Endpoint:** browse (trending videos) | - **Endpoint:** browse (trending videos) | ||||||
| - **Status:** Discontinued |  | ||||||
| 
 | 
 | ||||||
| YouTube moved the list of trending videos from the main _trending_ page to a separate | YouTube moved the list of trending videos from the main *trending* page to a | ||||||
| tab (Videos). | separate tab (Videos). | ||||||
| 
 | 
 | ||||||
| The video tab is fetched with the params `4gIOGgxtb3N0X3BvcHVsYXI%3D`. | The video tab is fetched with the params `4gIOGgxtb3N0X3BvcHVsYXI%3D`. | ||||||
| 
 | 
 | ||||||
|  | @ -305,799 +289,4 @@ The data model for the video shelves did not change. | ||||||
| 
 | 
 | ||||||
| **NEW** | **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`. |  | ||||||
| 
 |  | ||||||
| **OLD** |  | ||||||
| 
 |  | ||||||
| ```json |  | ||||||
| { |  | ||||||
|   "c4TabbedHeaderRenderer": { |  | ||||||
|     "avatar": { |  | ||||||
|       "thumbnails": [ |  | ||||||
|         { |  | ||||||
|           "height": 100, |  | ||||||
|           "url": "https://www.youtube.com/img/trending/avatar/trending_avatar.png", |  | ||||||
|           "width": 100 |  | ||||||
|         } |  | ||||||
|       ] |  | ||||||
|     }, |  | ||||||
|     "title": "Trending", |  | ||||||
|     "trackingParams": "CBAQ8DsiEwiXi_iUht76AhVM6hEIHfgTB2g=" |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| **NEW** |  | ||||||
| 
 |  | ||||||
| ```json |  | ||||||
| { |  | ||||||
|   "pageHeaderRenderer": { |  | ||||||
|     "pageTitle": "Trending", |  | ||||||
|     "content": { |  | ||||||
|       "pageHeaderViewModel": { |  | ||||||
|         "title": { |  | ||||||
|           "dynamicTextViewModel": { "text": { "content": "Trending" } } |  | ||||||
|         }, |  | ||||||
|         "image": { |  | ||||||
|           "contentPreviewImageViewModel": { |  | ||||||
|             "image": { |  | ||||||
|               "sources": [ |  | ||||||
|                 { |  | ||||||
|                   "url": "https://www.youtube.com/img/trending/avatar/trending.png", |  | ||||||
|                   "width": 100, |  | ||||||
|                   "height": 100 |  | ||||||
|                 } |  | ||||||
|               ] |  | ||||||
|             }, |  | ||||||
|             "style": "CONTENT_PREVIEW_IMAGE_STYLE_CIRCLE" |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ## [6] New Music Discography page |  | ||||||
| 
 |  | ||||||
| - **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 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. |  | ||||||
| 
 |  | ||||||
| **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:** Frequent (59%) |  | ||||||
| 
 |  | ||||||
| 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:** Stabilized |  | ||||||
| 
 |  | ||||||
| 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. |  | ||||||
| 
 |  | ||||||
| ## [21] Music album recommendations |  | ||||||
| 
 |  | ||||||
| - **Encountered on:** 26.02.2025 |  | ||||||
| - **Impact:** 🟢 Low |  | ||||||
| - **Endpoint:** browse (YTM) |  | ||||||
| - **Status:** Common (15%) |  | ||||||
| 
 |  | ||||||
|  |  | ||||||
| 
 |  | ||||||
| YouTube Music has added "Recommended" and "More from \<Artist\>" carousels to album |  | ||||||
| pages. The difficulty is distinguishing them reliably for parsing the album variants. |  | ||||||
| 
 |  | ||||||
| The current solution is adding the "Other versions" title in all languages to the |  | ||||||
| dictionary and comparing it. |  | ||||||
| 
 |  | ||||||
| ## [22] commandExecutorCommand for continuations |  | ||||||
| 
 |  | ||||||
| - **Encountered on:** 16.03.2025 |  | ||||||
| - **Impact:** 🟢 Low |  | ||||||
| - **Endpoint:** browse (YTM) |  | ||||||
| - **Status:** Experimental (1%) |  | ||||||
| 
 |  | ||||||
| YouTube playlists may use a commandExecutorCommand which holds a list of commands: the |  | ||||||
| `continuationCommand` that needs to be extracted as well as a `playlistVotingRefreshPopupCommand`. |  | ||||||
| 
 |  | ||||||
| ```json |  | ||||||
| { |  | ||||||
|   "continuationItemRenderer": { |  | ||||||
|     "continuationEndpoint": { |  | ||||||
|       "commandExecutorCommand": { |  | ||||||
|         "commands": [ |  | ||||||
|           { |  | ||||||
|             "playlistVotingRefreshPopupCommand": { |  | ||||||
|               "command": {} |  | ||||||
|             } |  | ||||||
|           }, |  | ||||||
|           { |  | ||||||
|             "continuationCommand": { |  | ||||||
|               "request": "CONTINUATION_REQUEST_TYPE_BROWSE", |  | ||||||
|               "token": "4qmFsgKBARIkVkxQTGJaSVB5MjAtMXBON21xamNrZXBXRjc4bmRiNmNpX3FpGjRDQUY2SGxCVU9rTklTV2xGUkVreVVtdEZOVTVFU1hsU2FrWkRVa1JKZWs1NldRJTNEJTNEmgIiUExiWklQeTIwLTFwTjdtcWpja2VwV0Y3OG5kYjZjaV9xaQ%3D%3D" |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         ] |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| Before Width: | Height: | Size: 43 KiB | 
| Before Width: | Height: | Size: 171 KiB | 
| Before Width: | Height: | Size: 290 KiB | 
| Before Width: | Height: | Size: 137 KiB | 
| Before Width: | Height: | Size: 242 KiB | 
| Before Width: | Height: | Size: 14 KiB | 
| Before Width: | Height: | Size: 13 KiB | 
| Before Width: | Height: | Size: 550 KiB | 
|  | @ -1,69 +0,0 @@ | ||||||
| # Channel order |  | ||||||
| 
 |  | ||||||
| Fields: |  | ||||||
| 
 |  | ||||||
| - `2:0:string` Channel ID |  | ||||||
| - `15:0:embedded` Videos tab |  | ||||||
| - `10:0:embedded` Shorts tab |  | ||||||
| - `14:0:embedded` Livestreams tab |  | ||||||
| - `2:0:string`: targetId for YouTube's web framework (`"\n$"` + any UUID) |  | ||||||
| - `3:1:varint` Sort order (1: Latest, 2: Popular) |  | ||||||
| 
 |  | ||||||
| Popular videos |  | ||||||
| 
 |  | ||||||
| ```json |  | ||||||
| { |  | ||||||
|   "80226972:0:embedded": { |  | ||||||
|     "2:0:string": "UCXuqSBlHAE6Xw-yeJA0Tunw", |  | ||||||
|     "3:1:base64": { |  | ||||||
|       "110:0:embedded": { |  | ||||||
|         "3:0:embedded": { |  | ||||||
|           "15:0:embedded": { |  | ||||||
|             "2:0:string": "\n$6461d7c8-0000-2040-87aa-089e0827e420", |  | ||||||
|             "3:1:varint": 2 |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Popular shorts |  | ||||||
| ```json |  | ||||||
| { |  | ||||||
|   "80226972:0:embedded": { |  | ||||||
|     "2:0:string": "UCXuqSBlHAE6Xw-yeJA0Tunw", |  | ||||||
|     "3:1:base64": { |  | ||||||
|       "110:0:embedded": { |  | ||||||
|         "3:0:embedded": { |  | ||||||
|           "10:0:embedded": { |  | ||||||
|             "2:0:string": "\n$64679ffb-0000-26b3-a1bd-582429d2c794", |  | ||||||
|             "3:1:varint": 2 |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Popular streams |  | ||||||
| 
 |  | ||||||
| ```json |  | ||||||
| { |  | ||||||
|   "80226972:0:embedded": { |  | ||||||
|     "2:0:string": "UCXuqSBlHAE6Xw-yeJA0Tunw", |  | ||||||
|     "3:1:base64": { |  | ||||||
|       "110:0:embedded": { |  | ||||||
|         "3:0:embedded": { |  | ||||||
|           "14:0:embedded": { |  | ||||||
|             "2:0:string": "\n$64693069-0000-2a1e-8c7d-582429bd5ba8", |  | ||||||
|             "3:1:varint": 2 |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
|  | @ -1,18 +0,0 @@ | ||||||
| Source: https://github.com/TeamNewPipe/NewPipe/pull/9182#issuecomment-1508938841 |  | ||||||
| 
 |  | ||||||
| Note: we recently discovered that YouTube system playlists exist for regular videos of channels, for livestreams, and shorts as chronological ones (the shorts one was already known) and popular ones. |  | ||||||
| They correspond basically to the results of the sort filters available on the channels streams tab on YouTube's interface |  | ||||||
| 
 |  | ||||||
| So, basically shortcuts for the lazy/incurious? |  | ||||||
| 
 |  | ||||||
| Same procedure as the one described in the 0.24.1 changelog, except that you need to change the prefix UU (all user uploads) to: |  | ||||||
| 
 |  | ||||||
| UULF for regular videos only, |  | ||||||
| UULV for livestreams only, |  | ||||||
| UUSH for shorts only, |  | ||||||
| UULP for popular regular videos, |  | ||||||
| UUPS for popular shorts, |  | ||||||
| UUPV for popular livestreams |  | ||||||
| UUMF: members only regular videos |  | ||||||
| UUMV: members only livestreams |  | ||||||
| UUMS is probably for members-only shorts, we need to found a channel making shorts restricted to channel members |  | ||||||
|  | @ -1,34 +0,0 @@ | ||||||
| # Parsing localized data from YouTube |  | ||||||
| 
 |  | ||||||
| Since YouTube's API is outputting the website as it should be rendered by the client, |  | ||||||
| the data received from the API is already localized. This affects dates, times and |  | ||||||
| number formats. |  | ||||||
| 
 |  | ||||||
| To be able to successfully parse them, we need to collect samples in every language and |  | ||||||
| build a dictionary. |  | ||||||
| 
 |  | ||||||
| ### Timeago |  | ||||||
| 
 |  | ||||||
| - Relative date format used for video upload dates and comments. |  | ||||||
| - Examples: "1 hour ago", "3 months ago" |  | ||||||
| 
 |  | ||||||
| ### Playlist dates |  | ||||||
| 
 |  | ||||||
| - Playlist update dates are always day-accurate, either as textual dates or in the form |  | ||||||
|   of "n days ago" |  | ||||||
| - Examples: "Last updated on Jan 3, 2020", "Updated today", "Updated yesterday", |  | ||||||
|   "Updated 3 days ago" |  | ||||||
| 
 |  | ||||||
| ### Video duration |  | ||||||
| 
 |  | ||||||
| - In Danisch ("da") video durations are formatted using dots instead of colons. Example: |  | ||||||
|   "12.31", "3.03.52" |  | ||||||
| 
 |  | ||||||
| ### Numbers |  | ||||||
| 
 |  | ||||||
| - Large numbers (subscriber/view counts) are rounded and shown using a decimal prefix |  | ||||||
| - Examples: "1.4M views" |  | ||||||
| - There is an exception for the value 0 ("no views") and in some languages for the value |  | ||||||
|   1 (pt: "Um vídeo") |  | ||||||
| - Special case: Language "gu", "જોવાયાની સંખ્યા" = "no views", contains no unique tokens |  | ||||||
|   to parse |  | ||||||
							
								
								
									
										
											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 |  | ||||||
|  | @ -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 |  | ||||||
| } |  | ||||||
							
								
								
									
										50
									
								
								src/cache.rs
									
										
									
									
									
								
							
							
						
						|  | @ -1,40 +1,20 @@ | ||||||
| //! # Persistent cache storage
 | //! Persistent cache storage
 | ||||||
| //!
 |  | ||||||
| //! RustyPipe caches some information fetched from YouTube: specifically
 |  | ||||||
| //! the client versions and the JavaScript code used to deobfuscate the stream URLs.
 |  | ||||||
| //!
 |  | ||||||
| //! Without a persistent cache storage, this information would have to be re-fetched
 |  | ||||||
| //! with every new instantiation of the client. This would make operation a lot slower,
 |  | ||||||
| //! especially with CLI applications. For this reason, persisting the cache between
 |  | ||||||
| //! program executions is recommended.
 |  | ||||||
| //!
 |  | ||||||
| //! Since there are many diferent ways to store this data (Text file, SQL, Redis, etc),
 |  | ||||||
| //! RustyPipe allows you to plug in your own cache storage by implementing the
 |  | ||||||
| //! [`CacheStorage`] trait.
 |  | ||||||
| //!
 |  | ||||||
| //! RustyPipe already comes with the [`FileStorage`] implementation which stores
 |  | ||||||
| //! the cache as a JSON file.
 |  | ||||||
| 
 | 
 | ||||||
| use std::{ | use std::{ | ||||||
|     fs::File, |     fs, | ||||||
|     io::Write, |  | ||||||
|     path::{Path, PathBuf}, |     path::{Path, PathBuf}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use tracing::error; | use log::error; | ||||||
| 
 | 
 | ||||||
| pub(crate) const DEFAULT_CACHE_FILE: &str = "rustypipe_cache.json"; |  | ||||||
| 
 |  | ||||||
| /// Cache storage trait
 |  | ||||||
| ///
 |  | ||||||
| /// RustyPipe has to cache some information fetched from YouTube: specifically
 | /// RustyPipe has to cache some information fetched from YouTube: specifically
 | ||||||
| /// the client versions and the JavaScript code used to deobfuscate the stream URLs.
 | /// the client versions and the JavaScript code used to deobfuscate the stream URLs.
 | ||||||
| ///
 | ///
 | ||||||
| /// This trait is used to abstract the cache storage behavior so you can store
 | /// This trait is used to abstract the cache storage behavior so you can store
 | ||||||
| /// cache data in your preferred way (File, SQL, Redis, etc).
 | /// cache data in your preferred way (File, SQL, Redis, etc).
 | ||||||
| ///
 | ///
 | ||||||
| /// The cache is read when building the [`RustyPipe`](crate::client::RustyPipe)
 | /// The cache is read when building the [`crate::client::RustyPipe`] client and updated
 | ||||||
| /// client and updated whenever additional data is fetched.
 | /// whenever additional data is fetched.
 | ||||||
| pub trait CacheStorage: Sync + Send { | pub trait CacheStorage: Sync + Send { | ||||||
|     /// Write the given string to the cache
 |     /// Write the given string to the cache
 | ||||||
|     fn write(&self, data: &str); |     fn write(&self, data: &str); | ||||||
|  | @ -62,28 +42,14 @@ impl FileStorage { | ||||||
| impl Default for FileStorage { | impl Default for FileStorage { | ||||||
|     fn default() -> Self { |     fn default() -> Self { | ||||||
|         Self { |         Self { | ||||||
|             path: Path::new(DEFAULT_CACHE_FILE).into(), |             path: Path::new("rustypipe_cache.json").into(), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl CacheStorage for FileStorage { | impl CacheStorage for FileStorage { | ||||||
|     fn write(&self, data: &str) { |     fn write(&self, data: &str) { | ||||||
|         fn _write(path: &Path, data: &str) -> Result<(), std::io::Error> { |         fs::write(&self.path, data).unwrap_or_else(|e| { | ||||||
|             let mut f = File::create(path)?; |  | ||||||
|             // Set cache file permissions to 0600 on Unix-based systems
 |  | ||||||
|             #[cfg(target_family = "unix")] |  | ||||||
|             { |  | ||||||
|                 use std::os::unix::fs::PermissionsExt; |  | ||||||
|                 let metadata = f.metadata()?; |  | ||||||
|                 let mut permissions = metadata.permissions(); |  | ||||||
|                 permissions.set_mode(0o600); |  | ||||||
|                 std::fs::set_permissions(path, permissions)?; |  | ||||||
|             } |  | ||||||
|             f.write_all(data.as_bytes()) |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         _write(&self.path, data).unwrap_or_else(|e| { |  | ||||||
|             error!( |             error!( | ||||||
|                 "Could not write cache to file `{}`. Error: {}", |                 "Could not write cache to file `{}`. Error: {}", | ||||||
|                 self.path.to_string_lossy(), |                 self.path.to_string_lossy(), | ||||||
|  | @ -97,7 +63,7 @@ impl CacheStorage for FileStorage { | ||||||
|             return None; |             return None; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         match std::fs::read_to_string(&self.path) { |         match fs::read_to_string(&self.path) { | ||||||
|             Ok(data) => Some(data), |             Ok(data) => Some(data), | ||||||
|             Err(e) => { |             Err(e) => { | ||||||
|                 error!( |                 error!( | ||||||
|  |  | ||||||
|  | @ -1,36 +1,30 @@ | ||||||
| use std::fmt::Debug; | use std::borrow::Cow; | ||||||
| 
 | 
 | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| use time::OffsetDateTime; |  | ||||||
| use url::Url; | use url::Url; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     client::response::YouTubeListItem, |  | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{ |     model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem, YouTubeItem}, | ||||||
|         paginator::{ContinuationEndpoint, Paginator}, |     param::Language, | ||||||
|         Channel, ChannelInfo, PlaylistItem, Verification, VideoItem, |     serializer::MapResult, | ||||||
|     }, |     util, | ||||||
|     param::{ChannelOrder, ChannelVideoTab, Language}, |  | ||||||
|     serializer::{text::TextComponent, MapResult}, |  | ||||||
|     util::{self, timeago, ProtoBuilder}, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext}; | ||||||
|     response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct QChannel<'a> { | struct QChannel<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     browse_id: &'a str, |     browse_id: &'a str, | ||||||
|     params: ChannelTab, |     params: Params, | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     query: Option<&'a str>, |     query: Option<&'a str>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| enum ChannelTab { | enum Params { | ||||||
|     #[serde(rename = "EgZ2aWRlb3PyBgQKAjoA")] |     #[serde(rename = "EgZ2aWRlb3PyBgQKAjoA")] | ||||||
|     Videos, |     Videos, | ||||||
|     #[serde(rename = "EgZzaG9ydHPyBgUKA5oBAA%3D%3D")] |     #[serde(rename = "EgZzaG9ydHPyBgUKA5oBAA%3D%3D")] | ||||||
|  | @ -39,30 +33,24 @@ enum ChannelTab { | ||||||
|     Live, |     Live, | ||||||
|     #[serde(rename = "EglwbGF5bGlzdHMgAQ%3D%3D")] |     #[serde(rename = "EglwbGF5bGlzdHMgAQ%3D%3D")] | ||||||
|     Playlists, |     Playlists, | ||||||
|  |     #[serde(rename = "EgVhYm91dPIGBAoCEgA%3D")] | ||||||
|  |     Info, | ||||||
|     #[serde(rename = "EgZzZWFyY2jyBgQKAloA")] |     #[serde(rename = "EgZzZWFyY2jyBgQKAloA")] | ||||||
|     Search, |     Search, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl From<ChannelVideoTab> for ChannelTab { |  | ||||||
|     fn from(value: ChannelVideoTab) -> Self { |  | ||||||
|         match value { |  | ||||||
|             ChannelVideoTab::Videos => Self::Videos, |  | ||||||
|             ChannelVideoTab::Shorts => Self::Shorts, |  | ||||||
|             ChannelVideoTab::Live => Self::Live, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     async fn _channel_videos<S: AsRef<str>>( |     async fn _channel_videos<S: AsRef<str>>( | ||||||
|         &self, |         &self, | ||||||
|         channel_id: S, |         channel_id: S, | ||||||
|         params: ChannelTab, |         params: Params, | ||||||
|         query: Option<&str>, |         query: Option<&str>, | ||||||
|         operation: &str, |         operation: &str, | ||||||
|     ) -> Result<Channel<Paginator<VideoItem>>, Error> { |     ) -> Result<Channel<Paginator<VideoItem>>, Error> { | ||||||
|         let channel_id = channel_id.as_ref(); |         let channel_id = channel_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::Desktop, true, None).await; | ||||||
|         let request_body = QChannel { |         let request_body = QChannel { | ||||||
|  |             context, | ||||||
|             browse_id: channel_id, |             browse_id: channel_id, | ||||||
|             params, |             params, | ||||||
|             query, |             query, | ||||||
|  | @ -79,67 +67,41 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get the videos from a YouTube channel
 |     /// Get the videos from a YouTube channel
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn channel_videos<S: AsRef<str>>( | ||||||
|     pub async fn channel_videos<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         channel_id: S, |         channel_id: S, | ||||||
|     ) -> Result<Channel<Paginator<VideoItem>>, Error> { |     ) -> Result<Channel<Paginator<VideoItem>>, Error> { | ||||||
|         self._channel_videos(channel_id, ChannelTab::Videos, None, "channel_videos") |         self._channel_videos(channel_id, Params::Videos, None, "channel_videos") | ||||||
|             .await |             .await | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get a ordered list of videos from a YouTube channel
 |     /// Get the short videos from a YouTube channel
 | ||||||
|     ///
 |     pub async fn channel_shorts<S: AsRef<str>>( | ||||||
|     /// This function does not return channel metadata.
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn channel_videos_order<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         channel_id: S, |         channel_id: S, | ||||||
|         order: ChannelOrder, |  | ||||||
|     ) -> Result<Paginator<VideoItem>, Error> { |  | ||||||
|         self.channel_videos_tab_order(channel_id, ChannelVideoTab::Videos, order) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// 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>( |  | ||||||
|         &self, |  | ||||||
|         channel_id: S, |  | ||||||
|         tab: ChannelVideoTab, |  | ||||||
|     ) -> Result<Channel<Paginator<VideoItem>>, Error> { |     ) -> Result<Channel<Paginator<VideoItem>>, Error> { | ||||||
|         self._channel_videos(channel_id, tab.into(), None, "channel_videos") |         self._channel_videos(channel_id, Params::Shorts, None, "channel_shorts") | ||||||
|             .await |             .await | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get a ordered list of videos from the given tab (Shorts, Livestreams) of a YouTube channel
 |     /// Get the livestreams from a YouTube channel
 | ||||||
|     ///
 |     pub async fn channel_livestreams<S: AsRef<str>>( | ||||||
|     /// This function does not return channel metadata.
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn channel_videos_tab_order<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         channel_id: S, |         channel_id: S, | ||||||
|         tab: ChannelVideoTab, |     ) -> Result<Channel<Paginator<VideoItem>>, Error> { | ||||||
|         order: ChannelOrder, |         self._channel_videos(channel_id, Params::Live, None, "channel_livestreams") | ||||||
|     ) -> Result<Paginator<VideoItem>, Error> { |  | ||||||
|         self.continuation( |  | ||||||
|             order_ctoken(channel_id.as_ref(), tab, order, &random_target()), |  | ||||||
|             ContinuationEndpoint::Browse, |  | ||||||
|             None, |  | ||||||
|         ) |  | ||||||
|             .await |             .await | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Search the videos of a channel
 |     /// Search the videos of a channel
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn channel_search<S: AsRef<str>, S2: AsRef<str>>( | ||||||
|     pub async fn channel_search<S: AsRef<str> + Debug, S2: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         channel_id: S, |         channel_id: S, | ||||||
|         query: S2, |         query: S2, | ||||||
|     ) -> Result<Channel<Paginator<VideoItem>>, Error> { |     ) -> Result<Channel<Paginator<VideoItem>>, Error> { | ||||||
|         self._channel_videos( |         self._channel_videos( | ||||||
|             channel_id, |             channel_id, | ||||||
|             ChannelTab::Search, |             Params::Search, | ||||||
|             Some(query.as_ref()), |             Some(query.as_ref()), | ||||||
|             "channel_search", |             "channel_search", | ||||||
|         ) |         ) | ||||||
|  | @ -147,15 +109,16 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get the playlists of a channel
 |     /// Get the playlists of a channel
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn channel_playlists<S: AsRef<str>>( | ||||||
|     pub async fn channel_playlists<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         channel_id: S, |         channel_id: S, | ||||||
|     ) -> Result<Channel<Paginator<PlaylistItem>>, Error> { |     ) -> Result<Channel<Paginator<PlaylistItem>>, Error> { | ||||||
|         let channel_id = channel_id.as_ref(); |         let channel_id = channel_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::Desktop, true, None).await; | ||||||
|         let request_body = QChannel { |         let request_body = QChannel { | ||||||
|  |             context, | ||||||
|             browse_id: channel_id, |             browse_id: channel_id, | ||||||
|             params: ChannelTab::Playlists, |             params: Params::Playlists, | ||||||
|             query: None, |             query: None, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -170,26 +133,25 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get additional metadata from the *About* tab of a channel
 |     /// Get additional metadata from the *About* tab of a channel
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn channel_info<S: AsRef<str>>( | ||||||
|     pub async fn channel_info<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         channel_id: S, |         channel_id: S, | ||||||
|     ) -> Result<ChannelInfo, Error> { |     ) -> Result<Channel<ChannelInfo>, Error> { | ||||||
|         let channel_id = channel_id.as_ref(); |         let channel_id = channel_id.as_ref(); | ||||||
|         let request_body = QContinuation { |         let context = self.get_context(ClientType::Desktop, true, None).await; | ||||||
|             continuation: &channel_info_ctoken(channel_id, &random_target()), |         let request_body = QChannel { | ||||||
|  |             context, | ||||||
|  |             browse_id: channel_id, | ||||||
|  |             params: Params::Info, | ||||||
|  |             query: None, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         self.execute_request_ctx::<response::ChannelAbout, _, _>( |         self.execute_request::<response::Channel, _, _>( | ||||||
|             ClientType::Desktop, |             ClientType::Desktop, | ||||||
|             "channel_info", |             "channel_info", | ||||||
|             channel_id, |             channel_id, | ||||||
|             "browse", |             "browse", | ||||||
|             &request_body, |             &request_body, | ||||||
|             MapRespOptions { |  | ||||||
|                 unlocalized: true, |  | ||||||
|                 ..Default::default() |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|         .await |         .await | ||||||
|     } |     } | ||||||
|  | @ -198,43 +160,38 @@ impl RustyPipeQuery { | ||||||
| impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel { | impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         id: &str, | ||||||
|  |         lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> { |     ) -> Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> { | ||||||
|         let content = map_channel_content(ctx.id, self.contents, self.alerts)?; |         let content = map_channel_content(self.contents, self.alerts)?; | ||||||
|         let visitor_data = self |  | ||||||
|             .response_context |  | ||||||
|             .visitor_data |  | ||||||
|             .or_else(|| ctx.visitor_data.map(str::to_owned)); |  | ||||||
| 
 | 
 | ||||||
|         let channel_data = map_channel( |         let channel_data = map_channel( | ||||||
|             MapChannelData { |             MapChannelData { | ||||||
|                 header: self.header, |                 header: self.header, | ||||||
|                 metadata: self.metadata, |                 metadata: self.metadata, | ||||||
|                 microformat: self.microformat, |                 microformat: self.microformat, | ||||||
|                 visitor_data: visitor_data.clone(), |                 visitor_data: self.response_context.visitor_data.clone(), | ||||||
|                 has_shorts: content.has_shorts, |                 has_shorts: content.has_shorts, | ||||||
|                 has_live: content.has_live, |                 has_live: content.has_live, | ||||||
|             }, |             }, | ||||||
|             ctx, |             id, | ||||||
|  |             lang, | ||||||
|         )?; |         )?; | ||||||
| 
 | 
 | ||||||
|         let mut mapper = response::YouTubeListMapper::<VideoItem>::with_channel( |         let mut mapper = | ||||||
|             ctx.lang, |             response::YouTubeListMapper::<VideoItem>::with_channel(lang, &channel_data); | ||||||
|             &channel_data.c, |  | ||||||
|             channel_data.warnings, |  | ||||||
|         ); |  | ||||||
|         mapper.map_response(content.content); |         mapper.map_response(content.content); | ||||||
|         let p = Paginator::new_ext( |         let p = Paginator::new_ext( | ||||||
|             None, |             None, | ||||||
|             mapper.items, |             mapper.items, | ||||||
|             mapper.ctoken, |             mapper.ctoken, | ||||||
|             visitor_data, |             self.response_context.visitor_data, | ||||||
|             ContinuationEndpoint::Browse, |             crate::model::paginator::ContinuationEndpoint::Browse, | ||||||
|             false, |  | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: combine_channel_data(channel_data.c, p), |             c: combine_channel_data(channel_data, p), | ||||||
|             warnings: mapper.warnings, |             warnings: mapper.warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  | @ -243,112 +200,90 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel { | ||||||
| impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel { | impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         id: &str, | ||||||
|  |         lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<Channel<Paginator<PlaylistItem>>>, ExtractionError> { |     ) -> Result<MapResult<Channel<Paginator<PlaylistItem>>>, ExtractionError> { | ||||||
|         let content = map_channel_content(ctx.id, self.contents, self.alerts)?; |         let content = map_channel_content(self.contents, self.alerts)?; | ||||||
|         let visitor_data = self |  | ||||||
|             .response_context |  | ||||||
|             .visitor_data |  | ||||||
|             .or_else(|| ctx.visitor_data.map(str::to_owned)); |  | ||||||
| 
 | 
 | ||||||
|         let channel_data = map_channel( |         let channel_data = map_channel( | ||||||
|             MapChannelData { |             MapChannelData { | ||||||
|                 header: self.header, |                 header: self.header, | ||||||
|                 metadata: self.metadata, |                 metadata: self.metadata, | ||||||
|                 microformat: self.microformat, |                 microformat: self.microformat, | ||||||
|                 visitor_data, |                 visitor_data: self.response_context.visitor_data, | ||||||
|                 has_shorts: content.has_shorts, |                 has_shorts: content.has_shorts, | ||||||
|                 has_live: content.has_live, |                 has_live: content.has_live, | ||||||
|             }, |             }, | ||||||
|             ctx, |             id, | ||||||
|  |             lang, | ||||||
|         )?; |         )?; | ||||||
| 
 | 
 | ||||||
|         let mut mapper = response::YouTubeListMapper::<PlaylistItem>::with_channel( |         let mut mapper = | ||||||
|             ctx.lang, |             response::YouTubeListMapper::<PlaylistItem>::with_channel(lang, &channel_data); | ||||||
|             &channel_data.c, |  | ||||||
|             channel_data.warnings, |  | ||||||
|         ); |  | ||||||
|         mapper.map_response(content.content); |         mapper.map_response(content.content); | ||||||
|         let p = Paginator::new(None, mapper.items, mapper.ctoken); |         let p = Paginator::new(None, mapper.items, mapper.ctoken); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: combine_channel_data(channel_data.c, p), |             c: combine_channel_data(channel_data, p), | ||||||
|             warnings: mapper.warnings, |             warnings: mapper.warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<ChannelInfo> for response::ChannelAbout { | impl MapResponse<Channel<ChannelInfo>> for response::Channel { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<ChannelInfo>, ExtractionError> { |     fn map_response( | ||||||
|         // Channel info is always fetched in English. There is no localized data
 |         self, | ||||||
|         // and it allows parsing the country name.
 |         id: &str, | ||||||
|         let lang = Language::En; |         lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<Channel<ChannelInfo>>, ExtractionError> { | ||||||
|  |         let content = map_channel_content(self.contents, self.alerts)?; | ||||||
|  |         let channel_data = map_channel( | ||||||
|  |             MapChannelData { | ||||||
|  |                 header: self.header, | ||||||
|  |                 metadata: self.metadata, | ||||||
|  |                 microformat: self.microformat, | ||||||
|  |                 visitor_data: self.response_context.visitor_data, | ||||||
|  |                 has_shorts: content.has_shorts, | ||||||
|  |                 has_live: content.has_live, | ||||||
|  |             }, | ||||||
|  |             id, | ||||||
|  |             lang, | ||||||
|  |         )?; | ||||||
| 
 | 
 | ||||||
|         let ep = match self { |         let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang); | ||||||
|             response::ChannelAbout::ReceivedEndpoints { |         mapper.map_response(content.content); | ||||||
|                 on_response_received_endpoints, |         let mut warnings = mapper.warnings; | ||||||
|             } => on_response_received_endpoints |  | ||||||
|                 .into_iter() |  | ||||||
|                 .next() |  | ||||||
|                 .ok_or(ExtractionError::InvalidData("no received endpoint".into()))?, |  | ||||||
|             response::ChannelAbout::Content { contents } => { |  | ||||||
|                 // Handle errors (e.g. age restriction) when regular channel content was returned
 |  | ||||||
|                 map_channel_content(ctx.id, contents, None)?; |  | ||||||
|                 return Err(ExtractionError::InvalidData( |  | ||||||
|                     "could not extract aboutData".into(), |  | ||||||
|                 )); |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
|         let continuations = ep.append_continuation_items_action.continuation_items; |  | ||||||
|         let about = continuations |  | ||||||
|             .c |  | ||||||
|             .into_iter() |  | ||||||
|             .next() |  | ||||||
|             .ok_or(ExtractionError::InvalidData("no aboutChannel data".into()))? |  | ||||||
|             .about_channel_renderer |  | ||||||
|             .metadata |  | ||||||
|             .about_channel_view_model; |  | ||||||
|         let mut warnings = continuations.warnings; |  | ||||||
| 
 | 
 | ||||||
|         let links = about |         let cinfo = mapper.channel_info.unwrap_or_else(|| { | ||||||
|             .links |             warnings.push("no aboutFullMetadata".to_owned()); | ||||||
|             .into_iter() |             ChannelInfo { | ||||||
|             .filter_map(|l| { |                 create_date: None, | ||||||
|                 let lv = l.channel_external_link_view_model; |                 view_count: None, | ||||||
|                 if let TextComponent::Web { url, .. } = lv.link { |                 links: Vec::new(), | ||||||
|                     Some((String::from(lv.title), util::sanitize_yt_url(&url))) |  | ||||||
|                 } else { |  | ||||||
|                     None |  | ||||||
|             } |             } | ||||||
|             }) |         }); | ||||||
|             .collect::<Vec<_>>(); |  | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: ChannelInfo { |             c: combine_channel_data(channel_data, cinfo), | ||||||
|                 id: about.channel_id, |  | ||||||
|                 url: about.canonical_channel_url, |  | ||||||
|                 description: about.description, |  | ||||||
|                 subscriber_count: about |  | ||||||
|                     .subscriber_count_text |  | ||||||
|                     .and_then(|txt| util::parse_large_numstr_or_warn(&txt, lang, &mut warnings)), |  | ||||||
|                 video_count: about |  | ||||||
|                     .video_count_text |  | ||||||
|                     .and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)), |  | ||||||
|                 create_date: about.joined_date_text.and_then(|txt| { |  | ||||||
|                     timeago::parse_textual_date_or_warn(lang, ctx.utc_offset, &txt, &mut warnings) |  | ||||||
|                         .map(OffsetDateTime::date) |  | ||||||
|                 }), |  | ||||||
|                 view_count: about |  | ||||||
|                     .view_count_text |  | ||||||
|                     .and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)), |  | ||||||
|                 country: about.country.and_then(|c| util::country_from_name(&c)), |  | ||||||
|                 links, |  | ||||||
|             }, |  | ||||||
|             warnings, |             warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | fn map_vanity_url(url: &str, id: &str) -> Option<String> { | ||||||
|  |     if url.contains(id) { | ||||||
|  |         return None; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Url::parse(url).ok().map(|mut parsed_url| { | ||||||
|  |         // The vanity URL from YouTube is http for some reason
 | ||||||
|  |         let _ = parsed_url.set_scheme("https"); | ||||||
|  |         parsed_url.to_string() | ||||||
|  |     }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| struct MapChannelData { | struct MapChannelData { | ||||||
|     header: Option<response::channel::Header>, |     header: Option<response::channel::Header>, | ||||||
|     metadata: Option<response::channel::Metadata>, |     metadata: Option<response::channel::Metadata>, | ||||||
|  | @ -360,149 +295,96 @@ struct MapChannelData { | ||||||
| 
 | 
 | ||||||
| fn map_channel( | fn map_channel( | ||||||
|     d: MapChannelData, |     d: MapChannelData, | ||||||
|     ctx: &MapRespCtx<'_>, |     id: &str, | ||||||
| ) -> Result<MapResult<Channel<()>>, ExtractionError> { |     lang: Language, | ||||||
|     let header = d.header.ok_or_else(|| ExtractionError::NotFound { | ) -> Result<Channel<()>, ExtractionError> { | ||||||
|         id: ctx.id.to_owned(), |     let header = d | ||||||
|         msg: "no header".into(), |         .header | ||||||
|     })?; |         .ok_or(ExtractionError::ContentUnavailable(Cow::Borrowed( | ||||||
|  |             "channel not found", | ||||||
|  |         )))?; | ||||||
|     let metadata = d |     let metadata = d | ||||||
|         .metadata |         .metadata | ||||||
|         .ok_or_else(|| ExtractionError::NotFound { |         .ok_or(ExtractionError::ContentUnavailable(Cow::Borrowed( | ||||||
|             id: ctx.id.to_owned(), |             "channel not found", | ||||||
|             msg: "no metadata".into(), |         )))? | ||||||
|         })? |  | ||||||
|         .channel_metadata_renderer; |         .channel_metadata_renderer; | ||||||
|     let microformat = d.microformat.ok_or_else(|| ExtractionError::NotFound { |     let microformat = d | ||||||
|         id: ctx.id.to_owned(), |         .microformat | ||||||
|         msg: "no microformat".into(), |         .ok_or(ExtractionError::ContentUnavailable(Cow::Borrowed( | ||||||
|     })?; |             "channel not found", | ||||||
|  |         )))?; | ||||||
| 
 | 
 | ||||||
|     if metadata.external_id != ctx.id { |     if metadata.external_id != id { | ||||||
|         return Err(ExtractionError::WrongResult(format!( |         return Err(ExtractionError::WrongResult(format!( | ||||||
|             "got wrong channel id {}, expected {}", |             "got wrong channel id {}, expected {}", | ||||||
|             metadata.external_id, ctx.id |             metadata.external_id, id | ||||||
|         ))); |         ))); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let handle = metadata |     let vanity_url = metadata | ||||||
|         .vanity_channel_url |         .vanity_channel_url | ||||||
|         .as_ref() |         .as_ref() | ||||||
|         .and_then(|url| Url::parse(url).ok()) |         .and_then(|url| map_vanity_url(url, id)); | ||||||
|         .and_then(|url| { |  | ||||||
|             url.path() |  | ||||||
|                 .strip_prefix('/') |  | ||||||
|                 .filter(|handle| util::CHANNEL_HANDLE_REGEX.is_match(handle)) |  | ||||||
|                 .map(str::to_owned) |  | ||||||
|         }); |  | ||||||
|     let mut warnings = Vec::new(); |  | ||||||
| 
 | 
 | ||||||
|     Ok(MapResult { |     Ok(match header { | ||||||
|         c: match header { |  | ||||||
|         response::channel::Header::C4TabbedHeaderRenderer(header) => Channel { |         response::channel::Header::C4TabbedHeaderRenderer(header) => Channel { | ||||||
|             id: metadata.external_id, |             id: metadata.external_id, | ||||||
|             name: metadata.title, |             name: metadata.title, | ||||||
|                 handle, |             subscriber_count: header | ||||||
|                 subscriber_count: header.subscriber_count_text.and_then(|txt| { |                 .subscriber_count_text | ||||||
|                     util::parse_large_numstr_or_warn(&txt, ctx.lang, &mut warnings) |                 .and_then(|txt| util::parse_large_numstr(&txt, lang)), | ||||||
|                 }), |  | ||||||
|                 video_count: None, |  | ||||||
|             avatar: header.avatar.into(), |             avatar: header.avatar.into(), | ||||||
|             verification: header.badges.into(), |             verification: header.badges.into(), | ||||||
|             description: metadata.description, |             description: metadata.description, | ||||||
|             tags: microformat.microformat_data_renderer.tags, |             tags: microformat.microformat_data_renderer.tags, | ||||||
|  |             vanity_url, | ||||||
|             banner: header.banner.into(), |             banner: header.banner.into(), | ||||||
|  |             mobile_banner: header.mobile_banner.into(), | ||||||
|  |             tv_banner: header.tv_banner.into(), | ||||||
|             has_shorts: d.has_shorts, |             has_shorts: d.has_shorts, | ||||||
|             has_live: d.has_live, |             has_live: d.has_live, | ||||||
|             visitor_data: d.visitor_data, |             visitor_data: d.visitor_data, | ||||||
|             content: (), |             content: (), | ||||||
|         }, |         }, | ||||||
|         response::channel::Header::CarouselHeaderRenderer(carousel) => { |         response::channel::Header::CarouselHeaderRenderer(carousel) => { | ||||||
|                 let hdata = carousel.contents.into_iter().find_map(|item| { |             let hdata = carousel | ||||||
|  |                 .contents | ||||||
|  |                 .into_iter() | ||||||
|  |                 .filter_map(|item| { | ||||||
|                     match item { |                     match item { | ||||||
|                 response::channel::CarouselHeaderRendererItem::TopicChannelDetailsRenderer { |                 response::channel::CarouselHeaderRendererItem::TopicChannelDetailsRenderer { | ||||||
|                     subscriber_count_text, |                     subscriber_count_text, | ||||||
|                     subtitle, |  | ||||||
|                     avatar, |                     avatar, | ||||||
|                 } => Some((subscriber_count_text.or(subtitle), avatar)), |                 } => Some((subscriber_count_text, avatar)), | ||||||
|                 response::channel::CarouselHeaderRendererItem::None => None, |                 response::channel::CarouselHeaderRendererItem::None => None, | ||||||
|             } |             } | ||||||
|                 }); |  | ||||||
| 
 |  | ||||||
|                 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) |  | ||||||
|                 }) |                 }) | ||||||
|                     }), |                 .next(); | ||||||
|                     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, |  | ||||||
|                     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_large_numstr_or_warn(t.as_str(), ctx.lang, &mut warnings) |  | ||||||
|                 }); |  | ||||||
| 
 | 
 | ||||||
|             Channel { |             Channel { | ||||||
|                 id: metadata.external_id, |                 id: metadata.external_id, | ||||||
|                 name: metadata.title, |                 name: metadata.title, | ||||||
|                     handle: handle.or_else(|| { |                 subscriber_count: hdata.as_ref().and_then(|hdata| { | ||||||
|                         md_rows |                     hdata | ||||||
|                             .first() |                         .0 | ||||||
|                             .and_then(|md| md.metadata_parts.get(1)) |                         .as_ref() | ||||||
|                             .map(|txt| txt.as_str().to_owned()) |                         .and_then(|txt| util::parse_large_numstr(txt, lang)) | ||||||
|                             .filter(|txt| util::CHANNEL_HANDLE_REGEX.is_match(txt)) |  | ||||||
|                 }), |                 }), | ||||||
|                     subscriber_count, |                 avatar: hdata.map(|hdata| hdata.1.into()).unwrap_or_default(), | ||||||
|                     video_count, |                 verification: crate::model::Verification::None, | ||||||
|                     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, |                 description: metadata.description, | ||||||
|                 tags: microformat.microformat_data_renderer.tags, |                 tags: microformat.microformat_data_renderer.tags, | ||||||
|                     banner: hdata.banner.image_banner_view_model.image.into(), |                 vanity_url, | ||||||
|  |                 banner: Vec::new(), | ||||||
|  |                 mobile_banner: Vec::new(), | ||||||
|  |                 tv_banner: Vec::new(), | ||||||
|                 has_shorts: d.has_shorts, |                 has_shorts: d.has_shorts, | ||||||
|                 has_live: d.has_live, |                 has_live: d.has_live, | ||||||
|                 visitor_data: d.visitor_data, |                 visitor_data: d.visitor_data, | ||||||
|                 content: (), |                 content: (), | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         }, |  | ||||||
|         warnings, |  | ||||||
|     }) |     }) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -513,13 +395,18 @@ struct MappedChannelContent { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_channel_content( | fn map_channel_content( | ||||||
|     id: &str, |  | ||||||
|     contents: Option<response::channel::Contents>, |     contents: Option<response::channel::Contents>, | ||||||
|     alerts: Option<Vec<response::Alert>>, |     alerts: Option<Vec<response::Alert>>, | ||||||
| ) -> Result<MappedChannelContent, ExtractionError> { | ) -> Result<MappedChannelContent, ExtractionError> { | ||||||
|     match contents { |     match contents { | ||||||
|         Some(contents) => { |         Some(contents) => { | ||||||
|             let tabs = contents.two_column_browse_results_renderer.contents; |             let tabs = contents.two_column_browse_results_renderer.tabs; | ||||||
|  |             if tabs.is_empty() { | ||||||
|  |                 return Err(ExtractionError::ContentUnavailable( | ||||||
|  |                     "channel not found".into(), | ||||||
|  |                 )); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|             let cmp_url_suffix = |endpoint: &response::channel::ChannelTabEndpoint, |             let cmp_url_suffix = |endpoint: &response::channel::ChannelTabEndpoint, | ||||||
|                                   expect: &str| { |                                   expect: &str| { | ||||||
|                 endpoint |                 endpoint | ||||||
|  | @ -534,58 +421,35 @@ fn map_channel_content( | ||||||
|             let mut featured_tab = false; |             let mut featured_tab = false; | ||||||
| 
 | 
 | ||||||
|             for tab in &tabs { |             for tab in &tabs { | ||||||
|                 if let Some(endpoint) = &tab.tab_renderer.endpoint { |                 if cmp_url_suffix(&tab.tab_renderer.endpoint, "/featured") | ||||||
|                     if cmp_url_suffix(endpoint, "/featured") |  | ||||||
|                     && (tab.tab_renderer.content.section_list_renderer.is_some() |                     && (tab.tab_renderer.content.section_list_renderer.is_some() | ||||||
|                         || tab.tab_renderer.content.rich_grid_renderer.is_some()) |                         || tab.tab_renderer.content.rich_grid_renderer.is_some()) | ||||||
|                 { |                 { | ||||||
|                     featured_tab = true; |                     featured_tab = true; | ||||||
|                     } else if cmp_url_suffix(endpoint, "/shorts") { |                 } else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/shorts") { | ||||||
|                     has_shorts = true; |                     has_shorts = true; | ||||||
|                     } else if cmp_url_suffix(endpoint, "/streams") { |                 } else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/streams") { | ||||||
|                     has_live = true; |                     has_live = true; | ||||||
|                 } |                 } | ||||||
|                 } else { |  | ||||||
|                     // Check for age gate
 |  | ||||||
|                     if let Some(YouTubeListItem::ChannelAgeGateRenderer { |  | ||||||
|                         channel_title, |  | ||||||
|                         main_text, |  | ||||||
|                     }) = &tab |  | ||||||
|                         .tab_renderer |  | ||||||
|                         .content |  | ||||||
|                         .section_list_renderer |  | ||||||
|                         .as_ref() |  | ||||||
|                         .and_then(|c| c.contents.c.first()) |  | ||||||
|                     { |  | ||||||
|                         return Err(ExtractionError::Unavailable { |  | ||||||
|                             reason: crate::error::UnavailabilityReason::AgeRestricted, |  | ||||||
|                             msg: format!("{channel_title}: {main_text}"), |  | ||||||
|                         }); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             let channel_content = tabs |             let channel_content = tabs.into_iter().find_map(|tab| { | ||||||
|                 .into_iter() |  | ||||||
|                 .filter(|t| t.tab_renderer.endpoint.is_some()) |  | ||||||
|                 .find_map(|tab| { |  | ||||||
|                 tab.tab_renderer |                 tab.tab_renderer | ||||||
|                     .content |                     .content | ||||||
|                     .rich_grid_renderer |                     .rich_grid_renderer | ||||||
|                     .or(tab.tab_renderer.content.section_list_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 = match channel_content { | ||||||
|             let content = if featured_tab { |  | ||||||
|                 MapResult::default() |  | ||||||
|             } else { |  | ||||||
|                 match channel_content { |  | ||||||
|                 Some(list) => list.contents, |                 Some(list) => list.contents, | ||||||
|                 None => { |                 None => { | ||||||
|                         return Err(ExtractionError::NotFound { |                     // YouTube may show the "Featured" tab if the requested tab is empty/does not exist
 | ||||||
|                             id: id.to_owned(), |                     if featured_tab { | ||||||
|                             msg: "no tabs".into(), |                         MapResult::default() | ||||||
|                         }); |                     } else { | ||||||
|  |                         return Err(ExtractionError::InvalidData(Cow::Borrowed( | ||||||
|  |                             "could not extract content", | ||||||
|  |                         ))); | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             }; |             }; | ||||||
|  | @ -596,7 +460,7 @@ fn map_channel_content( | ||||||
|                 has_live, |                 has_live, | ||||||
|             }) |             }) | ||||||
|         } |         } | ||||||
|         None => Err(response::alerts_to_err(id, alerts)), |         None => Err(response::alerts_to_err(alerts)), | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -604,14 +468,15 @@ fn combine_channel_data<T>(channel_data: Channel<()>, content: T) -> Channel<T> | ||||||
|     Channel { |     Channel { | ||||||
|         id: channel_data.id, |         id: channel_data.id, | ||||||
|         name: channel_data.name, |         name: channel_data.name, | ||||||
|         handle: channel_data.handle, |  | ||||||
|         subscriber_count: channel_data.subscriber_count, |         subscriber_count: channel_data.subscriber_count, | ||||||
|         video_count: channel_data.video_count, |  | ||||||
|         avatar: channel_data.avatar, |         avatar: channel_data.avatar, | ||||||
|         verification: channel_data.verification, |         verification: channel_data.verification, | ||||||
|         description: channel_data.description, |         description: channel_data.description, | ||||||
|         tags: channel_data.tags, |         tags: channel_data.tags, | ||||||
|  |         vanity_url: channel_data.vanity_url, | ||||||
|         banner: channel_data.banner, |         banner: channel_data.banner, | ||||||
|  |         mobile_banner: channel_data.mobile_banner, | ||||||
|  |         tv_banner: channel_data.tv_banner, | ||||||
|         has_shorts: channel_data.has_shorts, |         has_shorts: channel_data.has_shorts, | ||||||
|         has_live: channel_data.has_live, |         has_live: channel_data.has_live, | ||||||
|         visitor_data: channel_data.visitor_data, |         visitor_data: channel_data.visitor_data, | ||||||
|  | @ -619,88 +484,6 @@ 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( |  | ||||||
|     channel_id: &str, |  | ||||||
|     tab: ChannelVideoTab, |  | ||||||
|     order: ChannelOrder, |  | ||||||
|     target_id: &str, |  | ||||||
| ) -> 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), |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let mut pb_3 = ProtoBuilder::new(); |  | ||||||
|     pb_3.embedded(tab.order_ctoken_id(), pb_tab); |  | ||||||
| 
 |  | ||||||
|     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() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Get the continuation token to fetch channel
 |  | ||||||
| fn channel_info_ctoken(channel_id: &str, target_id: &str) -> String { |  | ||||||
|     let mut pb_3 = ProtoBuilder::new(); |  | ||||||
|     pb_3.string(19, target_id); |  | ||||||
| 
 |  | ||||||
|     let mut pb_110 = ProtoBuilder::new(); |  | ||||||
|     pb_110.embedded(3, pb_3); |  | ||||||
| 
 |  | ||||||
|     let mut pbi = ProtoBuilder::new(); |  | ||||||
|     pbi.embedded(110, pb_110); |  | ||||||
| 
 |  | ||||||
|     let mut pb_80226972 = ProtoBuilder::new(); |  | ||||||
|     pb_80226972.string(2, channel_id); |  | ||||||
|     pb_80226972.string(3, &pbi.to_base64()); |  | ||||||
| 
 |  | ||||||
|     let mut pb = ProtoBuilder::new(); |  | ||||||
|     pb.embedded(80_226_972, pb_80226972); |  | ||||||
| 
 |  | ||||||
|     pb.to_base64() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Create a random UUId to build continuation tokens
 |  | ||||||
| fn random_target() -> String { |  | ||||||
|     format!("\n${}", util::random_uuid()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod tests { | mod tests { | ||||||
|     use std::{fs::File, io::BufReader}; |     use std::{fs::File, io::BufReader}; | ||||||
|  | @ -709,16 +492,13 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use crate::{ |     use crate::{ | ||||||
|         client::{response, MapRespCtx, MapResponse}, |         client::{response, MapResponse}, | ||||||
|         error::{ExtractionError, UnavailabilityReason}, |  | ||||||
|         model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem}, |         model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem}, | ||||||
|         param::{ChannelOrder, ChannelVideoTab}, |         param::Language, | ||||||
|         serializer::MapResult, |         serializer::MapResult, | ||||||
|         util::tests::TESTFILES, |         util::tests::TESTFILES, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     use super::{channel_info_ctoken, order_ctoken}; |  | ||||||
| 
 |  | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::base("videos_base", "UC2DjFE7Xf11URZqWBigcVOQ")] |     #[case::base("videos_base", "UC2DjFE7Xf11URZqWBigcVOQ")] | ||||||
|     #[case::music("videos_music", "UC_vmjW5e1xEHhYjY2a0kK1A")] |     #[case::music("videos_music", "UC_vmjW5e1xEHhYjY2a0kK1A")] | ||||||
|  | @ -728,12 +508,8 @@ mod tests { | ||||||
|     #[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")] |     #[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")] | ||||||
|     #[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")] |     #[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")] | ||||||
|     #[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")] |     #[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")] | ||||||
|     #[case::coachella("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")] |  | ||||||
|     #[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")] |     #[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")] | ||||||
|     #[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")] |     #[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")] | ||||||
|     #[case::pageheader("shorts_20240129_pageheader", "UCh8gHdtzO2tXd593_bjErWg")] |  | ||||||
|     #[case::pageheader2("videos_20240324_pageheader2", "UC2DjFE7Xf11URZqWBigcVOQ")] |  | ||||||
|     #[case::lockup("shorts_20240910_lockup", "UCh8gHdtzO2tXd593_bjErWg")] |  | ||||||
|     fn map_channel_videos(#[case] name: &str, #[case] id: &str) { |     fn map_channel_videos(#[case] name: &str, #[case] id: &str) { | ||||||
|         let json_path = path!(*TESTFILES / "channel" / format!("channel_{name}.json")); |         let json_path = path!(*TESTFILES / "channel" / format!("channel_{name}.json")); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | @ -741,7 +517,7 @@ mod tests { | ||||||
|         let channel: response::Channel = |         let channel: response::Channel = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Channel<Paginator<VideoItem>>> = |         let map_res: MapResult<Channel<Paginator<VideoItem>>> = | ||||||
|             channel.map_response(&MapRespCtx::test(id)).unwrap(); |             channel.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -760,34 +536,15 @@ mod tests { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[test] |  | ||||||
|     fn channel_agegate() { |  | ||||||
|         let json_path = path!(*TESTFILES / "channel" / format!("channel_agegate.json")); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
| 
 |  | ||||||
|         let channel: response::Channel = |  | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let res: Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> = |  | ||||||
|             channel.map_response(&MapRespCtx::test("UCbfnHqxXs_K3kvaH-WlNlig")); |  | ||||||
|         if let Err(ExtractionError::Unavailable { reason, msg }) = res { |  | ||||||
|             assert_eq!(reason, UnavailabilityReason::AgeRestricted); |  | ||||||
|             assert!(msg.starts_with("Laphroaig Whisky: ")); |  | ||||||
|         } else { |  | ||||||
|             panic!("invalid res: {res:?}") |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::base("base")] |     fn map_channel_playlists() { | ||||||
|     #[case::lockup("20241109_lockup")] |         let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json"); | ||||||
|     fn map_channel_playlists(#[case] name: &str) { |  | ||||||
|         let json_path = path!(*TESTFILES / "channel" / format!("channel_playlists_{name}.json")); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let channel: response::Channel = |         let channel: response::Channel = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Channel<Paginator<PlaylistItem>>> = channel |         let map_res: MapResult<Channel<Paginator<PlaylistItem>>> = channel | ||||||
|             .map_response(&MapRespCtx::test("UC2DjFE7Xf11URZqWBigcVOQ")) |             .map_response("UC2DjFE7Xf11URZqWBigcVOQ", Language::En, None) | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|  | @ -795,7 +552,7 @@ mod tests { | ||||||
|             "deserialization/mapping warnings: {:?}", |             "deserialization/mapping warnings: {:?}", | ||||||
|             map_res.warnings |             map_res.warnings | ||||||
|         ); |         ); | ||||||
|         insta::assert_ron_snapshot!(format!("map_channel_playlists_{name}"), map_res.c); |         insta::assert_ron_snapshot!("map_channel_playlists", map_res.c); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|  | @ -803,10 +560,10 @@ mod tests { | ||||||
|         let json_path = path!(*TESTFILES / "channel" / "channel_info.json"); |         let json_path = path!(*TESTFILES / "channel" / "channel_info.json"); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let channel: response::ChannelAbout = |         let channel: response::Channel = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<ChannelInfo> = channel |         let map_res: MapResult<Channel<ChannelInfo>> = channel | ||||||
|             .map_response(&MapRespCtx::test("UC2DjFE7Xf11U-RZqWBigcVOQ")) |             .map_response("UC2DjFE7Xf11URZqWBigcVOQ", Language::En, None) | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|  | @ -816,41 +573,4 @@ mod tests { | ||||||
|         ); |         ); | ||||||
|         insta::assert_ron_snapshot!("map_channel_info", map_res.c); |         insta::assert_ron_snapshot!("map_channel_info", map_res.c); | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     #[test] |  | ||||||
|     fn t_order_ctoken() { |  | ||||||
|         let channel_id = "UCXuqSBlHAE6Xw-yeJA0Tunw"; |  | ||||||
| 
 |  | ||||||
|         let videos_popular_token = order_ctoken( |  | ||||||
|             channel_id, |  | ||||||
|             ChannelVideoTab::Videos, |  | ||||||
|             ChannelOrder::Popular, |  | ||||||
|             "\n$6461d7c8-0000-2040-87aa-089e0827e420", |  | ||||||
|         ); |  | ||||||
|         assert_eq!(videos_popular_token, "4qmFsgJgEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaRDhnWXdHaTU2TEJJbUNpUTJORFl4WkRkak9DMHdNREF3TFRJd05EQXRPRGRoWVMwd09EbGxNRGd5TjJVME1qQVlBaUFD"); |  | ||||||
| 
 |  | ||||||
|         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"); |  | ||||||
| 
 |  | ||||||
|         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"); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,10 +1,9 @@ | ||||||
| use std::fmt::Debug; | use std::collections::BTreeMap; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::ChannelRss, |     model::ChannelRss, | ||||||
|     report::Report, |     report::Report, | ||||||
|     util, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{response, RustyPipeQuery}; | use super::{response, RustyPipeQuery}; | ||||||
|  | @ -16,116 +15,52 @@ impl RustyPipeQuery { | ||||||
|     ///
 |     ///
 | ||||||
|     /// Fetching RSS feeds is a lot faster than querying the InnerTube API, so this method is great
 |     /// Fetching RSS feeds is a lot faster than querying the InnerTube API, so this method is great
 | ||||||
|     /// for checking a lot of channels or implementing a subscription feed.
 |     /// for checking a lot of channels or implementing a subscription feed.
 | ||||||
|     ///
 |     pub async fn channel_rss<S: AsRef<str>>(&self, channel_id: S) -> Result<ChannelRss, Error> { | ||||||
|     /// The downside of using the RSS feed is that it does not provide video durations.
 |         let url = format!( | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |             "https://www.youtube.com/feeds/videos.xml?channel_id={}", | ||||||
|     pub async fn channel_rss<S: AsRef<str> + Debug>( |             channel_id.as_ref() | ||||||
|         &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 |         let xml = self | ||||||
|             .client |             .client | ||||||
|             .http_request_txt(&self.client.inner.http.get(&url).build()?) |             .http_request_txt(self.client.inner.http.get(&url).build()?) | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| match e { |             .map_err(|e| match e { | ||||||
|                 Error::HttpStatus(404, _) => Error::Extraction(ExtractionError::NotFound { |                 Error::HttpStatus(404, _) => Error::Extraction( | ||||||
|                     id: channel_id.to_owned(), |                     ExtractionError::ContentUnavailable("Channel not found".into()), | ||||||
|                     msg: "404".into(), |                 ), | ||||||
|                 }), |  | ||||||
|                 _ => e, |                 _ => e, | ||||||
|             })?; |             })?; | ||||||
| 
 | 
 | ||||||
|         match quick_xml::de::from_str::<response::ChannelRss>(&xml) |         match quick_xml::de::from_str::<response::ChannelRss>(&xml) { | ||||||
|             .map_err(|e| ExtractionError::InvalidData(e.to_string().into())) |             Ok(feed) => Ok(feed.into()), | ||||||
|             .and_then(|feed| feed.map_response(channel_id)) |  | ||||||
|         { |  | ||||||
|             Ok(res) => Ok(res), |  | ||||||
|             Err(e) => { |             Err(e) => { | ||||||
|                 if let Some(reporter) = &self.client.inner.reporter { |                 if let Some(reporter) = &self.client.inner.reporter { | ||||||
|                     let report = Report { |                     let report = Report { | ||||||
|                         info: self.rp_info(), |                         info: Default::default(), | ||||||
|                         level: crate::report::Level::ERR, |                         level: crate::report::Level::ERR, | ||||||
|                         operation: "channel_rss", |                         operation: "channel_rss".to_owned(), | ||||||
|                         error: Some(e.to_string()), |                         error: Some(e.to_string()), | ||||||
|                         msgs: Vec::new(), |                         msgs: Vec::new(), | ||||||
|                         deobf_data: None, |                         deobf_data: None, | ||||||
|                         http_request: crate::report::HTTPRequest { |                         http_request: crate::report::HTTPRequest { | ||||||
|                             url: &url, |                             url, | ||||||
|                             method: "GET", |                             method: "GET".to_owned(), | ||||||
|  |                             req_header: BTreeMap::new(), | ||||||
|  |                             req_body: String::new(), | ||||||
|                             status: 200, |                             status: 200, | ||||||
|                             req_header: None, |  | ||||||
|                             req_body: None, |  | ||||||
|                             resp_body: xml, |                             resp_body: xml, | ||||||
|                         }, |                         }, | ||||||
|                     }; |                     }; | ||||||
| 
 | 
 | ||||||
|                     reporter.report(&report); |                     reporter.report(&report); | ||||||
|                 } |                 } | ||||||
|                 Err(Error::Extraction(e)) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| impl response::ChannelRss { |                 Err( | ||||||
|     fn map_response(self, id: &str) -> Result<ChannelRss, ExtractionError> { |                     ExtractionError::InvalidData(format!("could not deserialize xml: {e}").into()) | ||||||
|         let channel_id = if self.channel_id.is_empty() { |                         .into(), | ||||||
|             self.entry |                 ) | ||||||
|                 .iter() |  | ||||||
|                 .find_map(|entry| { |  | ||||||
|                     Some(entry.channel_id.as_str()) |  | ||||||
|                         .filter(|id| id.is_empty()) |  | ||||||
|                         .map(str::to_owned) |  | ||||||
|                 }) |  | ||||||
|                 .or_else(|| { |  | ||||||
|                     self.author |  | ||||||
|                         .uri |  | ||||||
|                         .strip_prefix("https://www.youtube.com/channel/") |  | ||||||
|                         .and_then(|id| { |  | ||||||
|                             if util::CHANNEL_ID_REGEX.is_match(id) { |  | ||||||
|                                 Some(id.to_owned()) |  | ||||||
|                             } else { |  | ||||||
|                                 None |  | ||||||
|             } |             } | ||||||
|                         }) |  | ||||||
|                 }) |  | ||||||
|                 .ok_or(ExtractionError::InvalidData( |  | ||||||
|                     "could not get channel id".into(), |  | ||||||
|                 ))? |  | ||||||
|         } else if self.channel_id.len() == 22 { |  | ||||||
|             // As of November 2023, YouTube seems to output channel IDs without the UC prefix
 |  | ||||||
|             format!("UC{}", self.channel_id) |  | ||||||
|         } else { |  | ||||||
|             self.channel_id |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         if channel_id != id { |  | ||||||
|             return Err(ExtractionError::WrongResult(format!( |  | ||||||
|                 "got wrong channel id {channel_id}, expected {id}", |  | ||||||
|             ))); |  | ||||||
|         } |         } | ||||||
| 
 |  | ||||||
|         Ok(ChannelRss { |  | ||||||
|             id: channel_id, |  | ||||||
|             name: self.title, |  | ||||||
|             videos: self |  | ||||||
|                 .entry |  | ||||||
|                 .into_iter() |  | ||||||
|                 .map(|item| crate::model::ChannelRssVideo { |  | ||||||
|                     id: item.video_id, |  | ||||||
|                     name: item.title, |  | ||||||
|                     description: item.media_group.description, |  | ||||||
|                     thumbnail: item.media_group.thumbnail.into(), |  | ||||||
|                     publish_date: item.published, |  | ||||||
|                     update_date: item.updated, |  | ||||||
|                     view_count: item.media_group.community.statistics.views, |  | ||||||
|                     like_count: item.media_group.community.rating.count, |  | ||||||
|                 }) |  | ||||||
|                 .collect(), |  | ||||||
|             create_date: self.create_date, |  | ||||||
|         }) |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -133,24 +68,24 @@ impl response::ChannelRss { | ||||||
| mod tests { | mod tests { | ||||||
|     use std::{fs::File, io::BufReader}; |     use std::{fs::File, io::BufReader}; | ||||||
| 
 | 
 | ||||||
|     use crate::{client::response, util::tests::TESTFILES}; |     use crate::{client::response, model::ChannelRss, util::tests::TESTFILES}; | ||||||
| 
 | 
 | ||||||
|     use path_macro::path; |     use path_macro::path; | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::base("base", "UCHnyfMqiRRG1u-2MsSQLbXA")] |     #[case::base("base")] | ||||||
|     #[case::no_likes("no_likes", "UCdfxp4cUWsWryZOy-o427dw")] |     #[case::no_likes("no_likes")] | ||||||
|     #[case::no_channel_id("no_channel_id", "UCHnyfMqiRRG1u-2MsSQLbXA")] |     #[case::no_channel_id("no_channel_id")] | ||||||
|     #[case::trimmed_channel_id("trimmed_channel_id", "UCHnyfMqiRRG1u-2MsSQLbXA")] |     fn map_channel_rss(#[case] name: &str) { | ||||||
|     fn map_channel_rss(#[case] name: &str, #[case] id: &str) { |  | ||||||
|         let xml_path = path!(*TESTFILES / "channel_rss" / format!("{}.xml", name)); |         let xml_path = path!(*TESTFILES / "channel_rss" / format!("{}.xml", name)); | ||||||
|         let xml_file = File::open(xml_path).unwrap(); |         let xml_file = File::open(xml_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let feed: response::ChannelRss = |         let feed: response::ChannelRss = | ||||||
|             quick_xml::de::from_reader(BufReader::new(xml_file)).unwrap(); |             quick_xml::de::from_reader(BufReader::new(xml_file)).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let map_res = feed.map_response(id).unwrap(); |         let map_res: ChannelRss = feed.into(); | ||||||
|  | 
 | ||||||
|         insta::assert_ron_snapshot!(format!("map_channel_rss_{}", name), map_res); |         insta::assert_ron_snapshot!(format!("map_channel_rss_{}", name), map_res); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										231
									
								
								src/client/channel_tv.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,231 @@ | ||||||
|  | use super::{ | ||||||
|  |     response, | ||||||
|  |     response::video_item::{IsLive, IsShort, IsUpcoming}, | ||||||
|  |     ClientType, MapResponse, QBrowse, RustyPipeQuery, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | use crate::{ | ||||||
|  |     error::{Error, ExtractionError}, | ||||||
|  |     model::{ChannelTag, ChannelTv, Verification, VideoItem}, | ||||||
|  |     param::Language, | ||||||
|  |     serializer::MapResult, | ||||||
|  |     timeago, | ||||||
|  |     util::{self, TryRemove}, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | impl RustyPipeQuery { | ||||||
|  |     /// Get the latest videos of a YouTube channel using the SmartTV client
 | ||||||
|  |     pub async fn channel_tv<S: AsRef<str>>(&self, channel_id: S) -> Result<ChannelTv, Error> { | ||||||
|  |         let channel_id = channel_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::TvHtml5, true, None).await; | ||||||
|  |         let request_body = QBrowse { | ||||||
|  |             browse_id: channel_id, | ||||||
|  |             context, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         self.execute_request::<response::ChannelTv, _, _>( | ||||||
|  |             ClientType::TvHtml5, | ||||||
|  |             "channel_tv", | ||||||
|  |             channel_id.as_ref(), | ||||||
|  |             "browse", | ||||||
|  |             &request_body, | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl MapResponse<ChannelTv> for response::ChannelTv { | ||||||
|  |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         id: &str, | ||||||
|  |         lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<ChannelTv>, ExtractionError> { | ||||||
|  |         // dbg!(&self);
 | ||||||
|  | 
 | ||||||
|  |         let cr = self | ||||||
|  |             .contents | ||||||
|  |             .tv_browse_renderer | ||||||
|  |             .content | ||||||
|  |             .tv_surface_content_renderer; | ||||||
|  | 
 | ||||||
|  |         let header = cr.header.tv_surface_header_renderer; | ||||||
|  |         let content = cr.content.section_list_renderer.contents; | ||||||
|  | 
 | ||||||
|  |         let subscribe_btn = header.buttons.into_iter().next(); | ||||||
|  |         let subscriber_count = subscribe_btn | ||||||
|  |             .as_ref() | ||||||
|  |             .and_then(|b| b.subscribe_button_renderer.subscriber_count_text.as_deref()) | ||||||
|  |             .and_then(|txt| util::parse_large_numstr(txt, lang)); | ||||||
|  |         let channel_id = subscribe_btn | ||||||
|  |             .map(|b| b.subscribe_button_renderer.channel_id) | ||||||
|  |             .unwrap_or_else(|| id.to_owned()); | ||||||
|  | 
 | ||||||
|  |         let uploads = content.into_iter().find(|shelf| { | ||||||
|  |             shelf | ||||||
|  |                 .shelf_renderer | ||||||
|  |                 .endpoint | ||||||
|  |                 .browse_endpoint | ||||||
|  |                 .as_ref() | ||||||
|  |                 .map(|ep| ep.params == "EgZ2aWRlb3MYAyAAcADyBgsKCToCCAGiAQIIAQ%3D%3D") | ||||||
|  |                 .unwrap_or_default() | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         let mut warnings = Vec::new(); | ||||||
|  |         let videos = uploads | ||||||
|  |             .map(|uploads| { | ||||||
|  |                 let mut items = uploads | ||||||
|  |                     .shelf_renderer | ||||||
|  |                     .content | ||||||
|  |                     .horizontal_list_renderer | ||||||
|  |                     .items; | ||||||
|  |                 warnings.append(&mut items.warnings); | ||||||
|  | 
 | ||||||
|  |                 items | ||||||
|  |                     .c | ||||||
|  |                     .into_iter() | ||||||
|  |                     .filter_map(|v| { | ||||||
|  |                         let v = v.tile_renderer; | ||||||
|  | 
 | ||||||
|  |                         match v.content_type { | ||||||
|  |                             response::channel_tv::ContentType::Video => { | ||||||
|  |                                 let h = v.header.tile_header_renderer; | ||||||
|  |                                 let mut m = v.metadata.tile_metadata_renderer; | ||||||
|  | 
 | ||||||
|  |                                 let length = h.thumbnail_overlays.first().and_then(|overlay| { | ||||||
|  |                                     util::parse_video_length( | ||||||
|  |                                         &overlay.thumbnail_overlay_time_status_renderer.text, | ||||||
|  |                                     ) | ||||||
|  |                                 }); | ||||||
|  | 
 | ||||||
|  |                                 let is_upcoming = h.thumbnail_overlays.is_upcoming(); | ||||||
|  | 
 | ||||||
|  |                                 // Normal video:
 | ||||||
|  |                                 // Line1: "Channel name", Line2: "View count" "•" "Upload date"
 | ||||||
|  |                                 // Current stream:
 | ||||||
|  |                                 // Line1: "Channel name", Line2: "10k watching"
 | ||||||
|  |                                 // Upcoming stream:
 | ||||||
|  |                                 // Line1: "Channel name", Line2: "Scheduled for 4/15/23, 12:00 AM"
 | ||||||
|  |                                 let (view_count, publish_date_txt) = m | ||||||
|  |                                     .lines | ||||||
|  |                                     .try_swap_remove(1) | ||||||
|  |                                     .map(|mut line| { | ||||||
|  |                                         let date_i = if is_upcoming { 0 } else { 2 }; | ||||||
|  | 
 | ||||||
|  |                                         let view_count = | ||||||
|  |                                             line.line_renderer.items.get(0).and_then(|vc| { | ||||||
|  |                                                 util::parse_large_numstr( | ||||||
|  |                                                     &vc.line_item_renderer.text, | ||||||
|  |                                                     lang, | ||||||
|  |                                                 ) | ||||||
|  |                                             }); | ||||||
|  |                                         let publish_date_txt = line | ||||||
|  |                                             .line_renderer | ||||||
|  |                                             .items | ||||||
|  |                                             .try_swap_remove(date_i) | ||||||
|  |                                             .map(|dt| dt.line_item_renderer.text); | ||||||
|  |                                         (view_count, publish_date_txt) | ||||||
|  |                                     }) | ||||||
|  |                                     .unwrap_or_default(); | ||||||
|  | 
 | ||||||
|  |                                 let publish_date = publish_date_txt.as_deref().and_then(|txt| { | ||||||
|  |                                     if is_upcoming { | ||||||
|  |                                         timeago::parse_datetime_or_warn(lang, txt, &mut warnings) | ||||||
|  |                                     } else { | ||||||
|  |                                         timeago::parse_textual_date_or_warn( | ||||||
|  |                                             lang, | ||||||
|  |                                             txt, | ||||||
|  |                                             &mut warnings, | ||||||
|  |                                         ) | ||||||
|  |                                     } | ||||||
|  |                                 }); | ||||||
|  | 
 | ||||||
|  |                                 Some(VideoItem { | ||||||
|  |                                     id: v.content_id, | ||||||
|  |                                     name: m.title, | ||||||
|  |                                     length, | ||||||
|  |                                     thumbnail: h.thumbnail.into(), | ||||||
|  |                                     channel: Some(ChannelTag { | ||||||
|  |                                         id: channel_id.to_owned(), | ||||||
|  |                                         name: header.title.to_owned(), | ||||||
|  |                                         avatar: Vec::new(), | ||||||
|  |                                         verification: Verification::None, | ||||||
|  |                                         subscriber_count, | ||||||
|  |                                     }), | ||||||
|  |                                     publish_date, | ||||||
|  |                                     publish_date_txt, | ||||||
|  |                                     view_count, | ||||||
|  |                                     is_live: h.thumbnail_overlays.is_live(), | ||||||
|  |                                     is_short: h.thumbnail_overlays.is_short(), | ||||||
|  |                                     is_upcoming, | ||||||
|  |                                     short_description: None, | ||||||
|  |                                 }) | ||||||
|  |                             } | ||||||
|  |                             _ => None, | ||||||
|  |                         } | ||||||
|  |                     }) | ||||||
|  |                     .collect() | ||||||
|  |             }) | ||||||
|  |             .unwrap_or_default(); | ||||||
|  | 
 | ||||||
|  |         Ok(MapResult { | ||||||
|  |             c: ChannelTv { | ||||||
|  |                 id: channel_id, | ||||||
|  |                 name: header.title, | ||||||
|  |                 subscriber_count, | ||||||
|  |                 avatar: header.thumbnail.into(), | ||||||
|  |                 tv_banner: header.banner.into(), | ||||||
|  |                 videos, | ||||||
|  |                 visitor_data: self.response_context.visitor_data, | ||||||
|  |             }, | ||||||
|  |             warnings, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[cfg(test)] | ||||||
|  | mod tests { | ||||||
|  |     use std::{fs::File, io::BufReader}; | ||||||
|  | 
 | ||||||
|  |     use path_macro::path; | ||||||
|  |     use rstest::rstest; | ||||||
|  | 
 | ||||||
|  |     use crate::{ | ||||||
|  |         client::{response, MapResponse}, | ||||||
|  |         model::ChannelTv, | ||||||
|  |         param::Language, | ||||||
|  |         serializer::MapResult, | ||||||
|  |         util::tests::TESTFILES, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     #[rstest] | ||||||
|  |     #[case::base("base", "UCXuqSBlHAE6Xw-yeJA0Tunw")] | ||||||
|  |     #[case::music("music", "UC_vmjW5e1xEHhYjY2a0kK1A")] | ||||||
|  |     #[case::live("live", "UCSJ4gkVC6NrvII8umztf0Ow")] | ||||||
|  |     #[case::live_upcoming("live_upcoming", "UC9CoZyztR-8Xok8Pptzpq1Q")] | ||||||
|  |     #[case::onevideo("onevideo", "UCAkeE1thnToEXZTao-CZkHw")] | ||||||
|  |     fn map_channel_videos(#[case] name: &str, #[case] id: &str) { | ||||||
|  |         let json_path = path!(*TESTFILES / "channel_tv" / format!("{name}.json")); | ||||||
|  |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | 
 | ||||||
|  |         let channel: response::ChannelTv = | ||||||
|  |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|  |         let map_res: MapResult<ChannelTv> = channel.map_response(id, Language::En, None).unwrap(); | ||||||
|  | 
 | ||||||
|  |         assert!( | ||||||
|  |             map_res.warnings.is_empty(), | ||||||
|  |             "deserialization/mapping warnings: {:?}", | ||||||
|  |             map_res.warnings | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         if name == "live_upcoming" { | ||||||
|  |             insta::assert_ron_snapshot!(format!("map_channel_{name}"), map_res.c, { | ||||||
|  |                 ".videos[1:].publish_date" => "[date]", | ||||||
|  |             }); | ||||||
|  |         } else { | ||||||
|  |             insta::assert_ron_snapshot!(format!("map_channel_{name}"), map_res.c, { | ||||||
|  |                 ".videos[].publish_date" => "[date]", | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										2513
									
								
								src/client/mod.rs
									
										
									
									
									
								
							
							
						
						|  | @ -1,27 +1,19 @@ | ||||||
| use std::borrow::Cow; | use std::{borrow::Cow, rc::Rc}; | ||||||
| 
 | 
 | ||||||
|  | use futures::{stream, StreamExt}; | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use regex::Regex; | use regex::Regex; | ||||||
| use tracing::debug; |  | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     client::{ |  | ||||||
|         response::{music_item::map_album_type, url_endpoint::NavigationEndpoint}, |  | ||||||
|         MapRespOptions, QContinuation, |  | ||||||
|     }, |  | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{ |     model::{AlbumItem, ArtistId, MusicArtist}, | ||||||
|         paginator::Paginator, traits::FromYtItem, AlbumItem, AlbumType, ArtistId, MusicArtist, |  | ||||||
|         MusicItem, |  | ||||||
|     }, |  | ||||||
|     param::{AlbumFilter, AlbumOrder}, |  | ||||||
|     serializer::MapResult, |     serializer::MapResult, | ||||||
|     util::{self, ProtoBuilder}, |     util::{self, TryRemove}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     response::{self, music_item::MusicListMapper, url_endpoint::PageType}, |     response::{self, music_item::MusicListMapper, url_endpoint::PageType}, | ||||||
|     ClientType, MapRespCtx, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery, |     ClientType, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|  | @ -34,23 +26,37 @@ impl RustyPipeQuery { | ||||||
|         all_albums: bool, |         all_albums: bool, | ||||||
|     ) -> Result<MusicArtist, Error> { |     ) -> Result<MusicArtist, Error> { | ||||||
|         let artist_id = artist_id.as_ref(); |         let artist_id = artist_id.as_ref(); | ||||||
|         let res = self._music_artist(artist_id, all_albums).await; |         let visitor_data = match all_albums { | ||||||
|  |             true => Some(self.get_ytm_visitor_data().await?), | ||||||
|  |             false => None, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         let res = self._music_artist(artist_id, visitor_data.as_deref()).await; | ||||||
| 
 | 
 | ||||||
|         if let Err(Error::Extraction(ExtractionError::Redirect(id))) = res { |         if let Err(Error::Extraction(ExtractionError::Redirect(id))) = res { | ||||||
|             debug!("music artist {} redirects to {}", artist_id, &id); |             log::debug!("music artist {} redirects to {}", artist_id, &id); | ||||||
|             self._music_artist(&id, all_albums).await |             self._music_artist(&id, visitor_data.as_deref()).await | ||||||
|         } else { |         } else { | ||||||
|             res |             res | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async fn _music_artist(&self, artist_id: &str, all_albums: bool) -> Result<MusicArtist, Error> { |     async fn _music_artist( | ||||||
|  |         &self, | ||||||
|  |         artist_id: &str, | ||||||
|  |         all_albums_vdata: Option<&str>, | ||||||
|  |     ) -> Result<MusicArtist, Error> { | ||||||
|  |         match all_albums_vdata { | ||||||
|  |             Some(visitor_data) => { | ||||||
|  |                 let context = self | ||||||
|  |                     .get_context(ClientType::DesktopMusic, true, Some(visitor_data)) | ||||||
|  |                     .await; | ||||||
|                 let request_body = QBrowse { |                 let request_body = QBrowse { | ||||||
|  |                     context, | ||||||
|                     browse_id: artist_id, |                     browse_id: artist_id, | ||||||
|                 }; |                 }; | ||||||
| 
 | 
 | ||||||
|         if all_albums { |                 let (mut artist, album_page_params) = self | ||||||
|             let (mut artist, can_fetch_more) = self |  | ||||||
|                     .execute_request::<response::MusicArtist, _, _>( |                     .execute_request::<response::MusicArtist, _, _>( | ||||||
|                         ClientType::DesktopMusic, |                         ClientType::DesktopMusic, | ||||||
|                         "music_artist", |                         "music_artist", | ||||||
|  | @ -60,14 +66,33 @@ impl RustyPipeQuery { | ||||||
|                     ) |                     ) | ||||||
|                     .await?; |                     .await?; | ||||||
| 
 | 
 | ||||||
|             if can_fetch_more { |                 let visitor_data = Rc::new(visitor_data); | ||||||
|                 artist.albums = self |                 let album_page_results = stream::iter(album_page_params) | ||||||
|                     .music_artist_albums(artist_id, None, Some(AlbumOrder::Recency)) |                     .map(|params| { | ||||||
|                     .await?; |                         let visitor_data = visitor_data.clone(); | ||||||
|  |                         async move { | ||||||
|  |                             self.music_artist_album_page(artist_id, ¶ms, &visitor_data) | ||||||
|  |                                 .await | ||||||
|  |                         } | ||||||
|  |                     }) | ||||||
|  |                     .buffer_unordered(2) | ||||||
|  |                     .collect::<Vec<_>>() | ||||||
|  |                     .await; | ||||||
|  | 
 | ||||||
|  |                 for res in album_page_results { | ||||||
|  |                     let mut res = res?; | ||||||
|  |                     artist.albums.append(&mut res); | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 Ok(artist) |                 Ok(artist) | ||||||
|         } else { |             } | ||||||
|  |             None => { | ||||||
|  |                 let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|  |                 let request_body = QBrowse { | ||||||
|  |                     context, | ||||||
|  |                     browse_id: artist_id, | ||||||
|  |                 }; | ||||||
|  | 
 | ||||||
|                 self.execute_request::<response::MusicArtist, _, _>( |                 self.execute_request::<response::MusicArtist, _, _>( | ||||||
|                     ClientType::DesktopMusic, |                     ClientType::DesktopMusic, | ||||||
|                     "music_artist", |                     "music_artist", | ||||||
|  | @ -78,61 +103,42 @@ impl RustyPipeQuery { | ||||||
|                 .await |                 .await | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     /// Get a list of all albums of a YouTube Music artist
 |     async fn music_artist_album_page( | ||||||
|     pub async fn music_artist_albums( |  | ||||||
|         &self, |         &self, | ||||||
|         artist_id: &str, |         artist_id: &str, | ||||||
|         filter: Option<AlbumFilter>, |         params: &str, | ||||||
|         order: Option<AlbumOrder>, |         visitor_data: &str, | ||||||
|     ) -> Result<Vec<AlbumItem>, Error> { |     ) -> Result<Vec<AlbumItem>, Error> { | ||||||
|  |         let context = self | ||||||
|  |             .get_context(ClientType::DesktopMusic, true, Some(visitor_data)) | ||||||
|  |             .await; | ||||||
|         let request_body = QBrowseParams { |         let request_body = QBrowseParams { | ||||||
|             browse_id: &format!("{}{}", util::ARTIST_DISCOGRAPHY_PREFIX, artist_id), |             context, | ||||||
|             params: &albums_param(filter, order), |             browse_id: artist_id, | ||||||
|  |             params, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let first_page = self |         self.execute_request::<response::MusicArtistAlbums, _, _>( | ||||||
|             .execute_request::<response::MusicArtistAlbums, _, _>( |  | ||||||
|             ClientType::DesktopMusic, |             ClientType::DesktopMusic, | ||||||
|             "music_artist_albums", |             "music_artist_albums", | ||||||
|             artist_id, |             artist_id, | ||||||
|             "browse", |             "browse", | ||||||
|             &request_body, |             &request_body, | ||||||
|         ) |         ) | ||||||
|             .await?; |         .await | ||||||
| 
 |  | ||||||
|         let mut albums = first_page.albums; |  | ||||||
|         let mut ctoken = first_page.ctoken; |  | ||||||
| 
 |  | ||||||
|         while let Some(tkn) = &ctoken { |  | ||||||
|             let request_body = QContinuation { continuation: tkn }; |  | ||||||
|             let resp: Paginator<MusicItem> = self |  | ||||||
|                 .execute_request_ctx::<response::MusicContinuation, Paginator<MusicItem>, _>( |  | ||||||
|                     ClientType::DesktopMusic, |  | ||||||
|                     "music_artist_albums_cont", |  | ||||||
|                     artist_id, |  | ||||||
|                     "browse", |  | ||||||
|                     &request_body, |  | ||||||
|                     MapRespOptions { |  | ||||||
|                         artist: Some(first_page.artist.clone()), |  | ||||||
|                         visitor_data: first_page.visitor_data.as_deref(), |  | ||||||
|                         ..Default::default() |  | ||||||
|                     }, |  | ||||||
|                 ) |  | ||||||
|                 .await?; |  | ||||||
|             if resp.items.is_empty() { |  | ||||||
|                 tracing::warn!("artist albums [{artist_id}] empty continuation"); |  | ||||||
|             } |  | ||||||
|             ctoken = resp.ctoken; |  | ||||||
|             albums.extend(resp.items.into_iter().filter_map(AlbumItem::from_ytm_item)); |  | ||||||
|         } |  | ||||||
|         Ok(albums) |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<MusicArtist> for response::MusicArtist { | impl MapResponse<MusicArtist> for response::MusicArtist { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicArtist>, ExtractionError> { |     fn map_response( | ||||||
|         let mapped = map_artist_page(self, ctx, false)?; |         self, | ||||||
|  |         id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<MusicArtist>, ExtractionError> { | ||||||
|  |         let mapped = map_artist_page(self, id, lang, false)?; | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: mapped.c.0, |             c: mapped.c.0, | ||||||
|             warnings: mapped.warnings, |             warnings: mapped.warnings, | ||||||
|  | @ -140,38 +146,26 @@ impl MapResponse<MusicArtist> for response::MusicArtist { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<(MusicArtist, bool)> for response::MusicArtist { | impl MapResponse<(MusicArtist, Vec<String>)> for response::MusicArtist { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         id: &str, | ||||||
|     ) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> { |         lang: crate::param::Language, | ||||||
|         map_artist_page(self, ctx, true) |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<(MusicArtist, Vec<String>)>, ExtractionError> { | ||||||
|  |         map_artist_page(self, id, lang, true) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_artist_page( | fn map_artist_page( | ||||||
|     res: response::MusicArtist, |     res: response::MusicArtist, | ||||||
|     ctx: &MapRespCtx<'_>, |     id: &str, | ||||||
|  |     lang: crate::param::Language, | ||||||
|     skip_extendables: bool, |     skip_extendables: bool, | ||||||
| ) -> Result<MapResult<(MusicArtist, bool)>, ExtractionError> { | ) -> Result<MapResult<(MusicArtist, Vec<String>)>, ExtractionError> { | ||||||
|     let contents = match res.contents { |     // dbg!(&res);
 | ||||||
|         Some(c) => c, |  | ||||||
|         None => { |  | ||||||
|             if res.microformat.microformat_data_renderer.noindex { |  | ||||||
|                 return Err(ExtractionError::NotFound { |  | ||||||
|                     id: ctx.id.to_owned(), |  | ||||||
|                     msg: "no contents".into(), |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 return Err(ExtractionError::InvalidData("no contents".into())); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| 
 | 
 | ||||||
|     let header = res |     let header = res.header.music_immersive_header_renderer; | ||||||
|         .header |  | ||||||
|         .ok_or(ExtractionError::InvalidData("no header".into()))? |  | ||||||
|         .music_immersive_header_renderer; |  | ||||||
| 
 | 
 | ||||||
|     if let Some(share) = header.share_endpoint { |     if let Some(share) = header.share_endpoint { | ||||||
|         let pb = share.share_entity_endpoint.serialized_share_entity; |         let pb = share.share_entity_endpoint.serialized_share_entity; | ||||||
|  | @ -182,31 +176,33 @@ fn map_artist_page( | ||||||
|             .and_then(|pb| util::string_from_pb(pb, 3)); |             .and_then(|pb| util::string_from_pb(pb, 3)); | ||||||
| 
 | 
 | ||||||
|         if let Some(share_channel_id) = share_channel_id { |         if let Some(share_channel_id) = share_channel_id { | ||||||
|             if share_channel_id != ctx.id { |             if share_channel_id != id { | ||||||
|                 return Err(ExtractionError::Redirect(share_channel_id)); |                 return Err(ExtractionError::Redirect(share_channel_id)); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let sections = contents |     let sections = res | ||||||
|  |         .contents | ||||||
|         .single_column_browse_results_renderer |         .single_column_browse_results_renderer | ||||||
|         .contents |         .contents | ||||||
|         .into_iter() |         .into_iter() | ||||||
|         .next() |         .next() | ||||||
|         .map(|c| c.tab_renderer.content.section_list_renderer.contents) |         .and_then(|tab| tab.tab_renderer.content) | ||||||
|  |         .map(|c| c.section_list_renderer.contents) | ||||||
|         .unwrap_or_default(); |         .unwrap_or_default(); | ||||||
| 
 | 
 | ||||||
|     let mut mapper = MusicListMapper::with_artist( |     let mut mapper = MusicListMapper::with_artist( | ||||||
|         ctx.lang, |         lang, | ||||||
|         ArtistId { |         ArtistId { | ||||||
|             id: Some(ctx.id.to_owned()), |             id: Some(id.to_owned()), | ||||||
|             name: header.title.clone(), |             name: header.title.to_owned(), | ||||||
|         }, |         }, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     let mut tracks_playlist_id = None; |     let mut tracks_playlist_id = None; | ||||||
|     let mut videos_playlist_id = None; |     let mut videos_playlist_id = None; | ||||||
|     let mut can_fetch_more = false; |     let mut album_page_params = Vec::new(); | ||||||
| 
 | 
 | ||||||
|     for section in sections { |     for section in sections { | ||||||
|         match section { |         match section { | ||||||
|  | @ -224,56 +220,45 @@ fn map_artist_page( | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|                 mapper.album_type = AlbumType::Single; | 
 | ||||||
|                 mapper.map_response(shelf.contents); |                 mapper.map_response(shelf.contents); | ||||||
|             } |             } | ||||||
|             response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { |             response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { | ||||||
|                 let mut extendable_albums = false; |                 let mut extendable_albums = false; | ||||||
|                 mapper.album_type = AlbumType::Single; |  | ||||||
|                 if let Some(h) = shelf.header { |                 if let Some(h) = shelf.header { | ||||||
|                     if let Some(button) = h |                     if let Some(button) = h | ||||||
|                         .music_carousel_shelf_basic_header_renderer |                         .music_carousel_shelf_basic_header_renderer | ||||||
|                         .more_content_button |                         .more_content_button | ||||||
|                     { |                     { | ||||||
|                         if let NavigationEndpoint::Browse { |                         if let Some(bep) = | ||||||
|                             browse_endpoint, .. |                             button.button_renderer.navigation_endpoint.browse_endpoint | ||||||
|                         } = button.button_renderer.navigation_endpoint |  | ||||||
|                         { |                         { | ||||||
|  |                             if let Some(cfg) = bep.browse_endpoint_context_supported_configs { | ||||||
|  |                                 match cfg.browse_endpoint_context_music_config.page_type { | ||||||
|                                     // Music videos
 |                                     // Music videos
 | ||||||
|                             if browse_endpoint |                                     PageType::Playlist => { | ||||||
|                                 .browse_endpoint_context_supported_configs |  | ||||||
|                                 .map(|cfg| { |  | ||||||
|                                     cfg.browse_endpoint_context_music_config.page_type |  | ||||||
|                                         == PageType::Playlist |  | ||||||
|                                 }) |  | ||||||
|                                 .unwrap_or_default() |  | ||||||
|                             { |  | ||||||
|                                         if videos_playlist_id.is_none() { |                                         if videos_playlist_id.is_none() { | ||||||
|                                     videos_playlist_id = Some(browse_endpoint.browse_id); |                                             videos_playlist_id = Some(bep.browse_id); | ||||||
|                                         } |                                         } | ||||||
|                             } else if browse_endpoint |                                     } | ||||||
|                                 .browse_id |                                     // Albums or playlists
 | ||||||
|                                 .starts_with(util::ARTIST_DISCOGRAPHY_PREFIX) |                                     PageType::Artist => { | ||||||
|                             { |  | ||||||
|                                 can_fetch_more = true; |  | ||||||
|                                 extendable_albums = true; |  | ||||||
|                             } else { |  | ||||||
|                                         // Peek at the first item to determine type
 |                                         // Peek at the first item to determine type
 | ||||||
|                                         if let Some(response::music_item::MusicResponseItem::MusicTwoRowItemRenderer(item)) = shelf.contents.c.first() { |                                         if let Some(response::music_item::MusicResponseItem::MusicTwoRowItemRenderer(item)) = shelf.contents.c.first() { | ||||||
|                                     if let Some(PageType::Album) = item.navigation_endpoint.page_type() { |                                             if let Some(PageType::Album) = item.navigation_endpoint.browse_endpoint.as_ref().and_then(|be| { | ||||||
|                                         can_fetch_more = true; |                                                 be.browse_endpoint_context_supported_configs.as_ref().map(|config| { | ||||||
|  |                                                         config.browse_endpoint_context_music_config.page_type | ||||||
|  |                                                 })}) { | ||||||
|  |                                                     album_page_params.push(bep.params); | ||||||
|                                                     extendable_albums = true; |                                                     extendable_albums = true; | ||||||
|                                                 } |                                                 } | ||||||
|                                         } |                                         } | ||||||
|                                     } |                                     } | ||||||
|  |                                     _ => {} | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                     mapper.album_type = map_album_type( |  | ||||||
|                         h.music_carousel_shelf_basic_header_renderer |  | ||||||
|                             .title |  | ||||||
|                             .first_str(), |  | ||||||
|                         ctx.lang, |  | ||||||
|                     ); |  | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 if !skip_extendables || !extendable_albums { |                 if !skip_extendables || !extendable_albums { | ||||||
|  | @ -284,7 +269,7 @@ fn map_artist_page( | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let mut mapped = mapper.group_items(); |     let mapped = mapper.group_items(); | ||||||
| 
 | 
 | ||||||
|     static WIKIPEDIA_REGEX: Lazy<Regex> = |     static WIKIPEDIA_REGEX: Lazy<Regex> = | ||||||
|         Lazy::new(|| Regex::new(r"\(?https://[a-z\d-]+\.wikipedia.org/wiki/[^\s]+").unwrap()); |         Lazy::new(|| Regex::new(r"\(?https://[a-z\d-]+\.wikipedia.org/wiki/[^\s]+").unwrap()); | ||||||
|  | @ -302,27 +287,24 @@ fn map_artist_page( | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     let radio_id = header.start_radio_button.and_then(|b| { |     let radio_id = header.start_radio_button.and_then(|b| { | ||||||
|         if let NavigationEndpoint::Watch { watch_endpoint } = b.button_renderer.navigation_endpoint |         b.button_renderer | ||||||
|         { |             .navigation_endpoint | ||||||
|             watch_endpoint.playlist_id |             .watch_endpoint | ||||||
|         } else { |             .and_then(|w| w.playlist_id) | ||||||
|             None |  | ||||||
|         } |  | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     Ok(MapResult { |     Ok(MapResult { | ||||||
|         c: ( |         c: ( | ||||||
|             MusicArtist { |             MusicArtist { | ||||||
|                 id: ctx.id.to_owned(), |                 id: id.to_owned(), | ||||||
|                 name: header.title, |                 name: header.title, | ||||||
|                 header_image: header.thumbnail.into(), |                 header_image: header.thumbnail.into(), | ||||||
|                 description: header.description, |                 description: header.description, | ||||||
|                 wikipedia_url, |                 wikipedia_url, | ||||||
|                 subscriber_count: header.subscription_button.and_then(|btn| { |                 subscriber_count: header.subscription_button.and_then(|btn| { | ||||||
|                     util::parse_large_numstr_or_warn( |                     util::parse_large_numstr( | ||||||
|                         &btn.subscribe_button_renderer.subscriber_count_text, |                         &btn.subscribe_button_renderer.subscriber_count_text, | ||||||
|                         ctx.lang, |                         lang, | ||||||
|                         &mut mapped.warnings, |  | ||||||
|                     ) |                     ) | ||||||
|                 }), |                 }), | ||||||
|                 tracks: mapped.c.tracks, |                 tracks: mapped.c.tracks, | ||||||
|  | @ -333,94 +315,51 @@ fn map_artist_page( | ||||||
|                 videos_playlist_id, |                 videos_playlist_id, | ||||||
|                 radio_id, |                 radio_id, | ||||||
|             }, |             }, | ||||||
|             can_fetch_more, |             album_page_params, | ||||||
|         ), |         ), | ||||||
|         warnings: mapped.warnings, |         warnings: mapped.warnings, | ||||||
|     }) |     }) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug)] | impl MapResponse<Vec<AlbumItem>> for response::MusicArtistAlbums { | ||||||
| struct FirstAlbumPage { |  | ||||||
|     albums: Vec<AlbumItem>, |  | ||||||
|     ctoken: Option<String>, |  | ||||||
|     artist: ArtistId, |  | ||||||
|     visitor_data: Option<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl MapResponse<FirstAlbumPage> for response::MusicArtistAlbums { |  | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         id: &str, | ||||||
|     ) -> Result<MapResult<FirstAlbumPage>, ExtractionError> { |         lang: crate::param::Language, | ||||||
|         let Some(header) = self.header else { |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|             return Err(ExtractionError::NotFound { |     ) -> Result<MapResult<Vec<AlbumItem>>, ExtractionError> { | ||||||
|                 id: ctx.id.into(), |         // dbg!(&self);
 | ||||||
|                 msg: "no header".into(), |  | ||||||
|             }); |  | ||||||
|         }; |  | ||||||
| 
 | 
 | ||||||
|         let grids = self |         let mut content = self.contents.single_column_browse_results_renderer.contents; | ||||||
|             .contents |         let grids = content | ||||||
|             .single_column_browse_results_renderer |             .try_swap_remove(0) | ||||||
|             .contents |  | ||||||
|             .into_iter() |  | ||||||
|             .next() |  | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? | ||||||
|             .tab_renderer |             .tab_renderer | ||||||
|             .content |             .content | ||||||
|             .section_list_renderer |             .section_list_renderer | ||||||
|             .contents; |             .contents; | ||||||
| 
 | 
 | ||||||
|         let artist_id = ArtistId { |         let mut mapper = MusicListMapper::with_artist( | ||||||
|             id: Some(ctx.id.to_owned()), |             lang, | ||||||
|             name: header.music_header_renderer.title, |             ArtistId { | ||||||
|         }; |                 id: Some(id.to_owned()), | ||||||
|         let mut mapper = MusicListMapper::with_artist(ctx.lang, artist_id.clone()); |                 name: self.header.music_header_renderer.title, | ||||||
|         let mut ctoken = None; |             }, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|         for grid in grids { |         for grid in grids { | ||||||
|             mapper.map_response(grid.grid_renderer.items); |             mapper.map_response(grid.grid_renderer.items); | ||||||
|             if ctoken.is_none() { |  | ||||||
|                 ctoken = grid |  | ||||||
|                     .grid_renderer |  | ||||||
|                     .continuations |  | ||||||
|                     .into_iter() |  | ||||||
|                     .next() |  | ||||||
|                     .map(|g| g.next_continuation_data.continuation); |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let mapped = mapper.group_items(); |         let mapped = mapper.group_items(); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: FirstAlbumPage { |             c: mapped.c.albums, | ||||||
|                 albums: mapped.c.albums, |  | ||||||
|                 ctoken, |  | ||||||
|                 artist: artist_id, |  | ||||||
|                 visitor_data: ctx.visitor_data.map(str::to_owned), |  | ||||||
|             }, |  | ||||||
|             warnings: mapped.warnings, |             warnings: mapped.warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn albums_param(filter: Option<AlbumFilter>, order: Option<AlbumOrder>) -> String { |  | ||||||
|     let mut pb_filter = ProtoBuilder::new(); |  | ||||||
|     if let Some(filter) = filter { |  | ||||||
|         pb_filter.varint(1, filter as u64); |  | ||||||
|     } |  | ||||||
|     if let Some(order) = order { |  | ||||||
|         pb_filter.varint(2, order as u64); |  | ||||||
|     } |  | ||||||
|     pb_filter.bytes(3, &[1, 2]); |  | ||||||
| 
 |  | ||||||
|     let mut pb_48 = ProtoBuilder::new(); |  | ||||||
|     pb_48.embedded(15, pb_filter); |  | ||||||
| 
 |  | ||||||
|     let mut pb_3 = ProtoBuilder::new(); |  | ||||||
|     pb_3.embedded(48, pb_48); |  | ||||||
|     pb_3.to_base64() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod tests { | mod tests { | ||||||
|     use std::{fs::File, io::BufReader}; |     use std::{fs::File, io::BufReader}; | ||||||
|  | @ -428,75 +367,55 @@ mod tests { | ||||||
|     use path_macro::path; |     use path_macro::path; | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use crate::util::tests::TESTFILES; |     use crate::{param::Language, util::tests::TESTFILES}; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::default("default", "UClmXPfaYhXOYsNn_QUyheWQ")] |     #[case::default("default", "UClmXPfaYhXOYsNn_QUyheWQ")] | ||||||
|  |     #[case::no_more_albums("no_more_albums", "UC_vmjW5e1xEHhYjY2a0kK1A")] | ||||||
|     #[case::only_singles("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw")] |     #[case::only_singles("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw")] | ||||||
|     #[case::no_artist("no_artist", "UCh8gHdtzO2tXd593_bjErWg")] |     #[case::no_artist("no_artist", "UCh8gHdtzO2tXd593_bjErWg")] | ||||||
|     #[case::only_more_singles("only_more_singles", "UC0aXrjVxG5pZr99v77wZdPQ")] |     #[case::only_more_singles("only_more_singles", "UC0aXrjVxG5pZr99v77wZdPQ")] | ||||||
|     #[case::grouped_albums("20250113_grouped_albums", "UCOR4_bSVIXPsGa4BbCSt60Q")] |  | ||||||
|     fn map_music_artist(#[case] name: &str, #[case] id: &str) { |     fn map_music_artist(#[case] name: &str, #[case] id: &str) { | ||||||
|         let json_path = path!(*TESTFILES / "music_artist" / format!("artist_{name}.json")); |         let json_path = path!(*TESTFILES / "music_artist" / format!("artist_{name}.json")); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let mut album_page_path = None; |         let mut album_page_paths = Vec::new(); | ||||||
|         let json_path = path!(*TESTFILES / "music_artist" / format!("artist_{name}_1.json")); |         for i in 1..=2 { | ||||||
|         if json_path.exists() { |             let json_path = path!(*TESTFILES / "music_artist" / format!("artist_{name}_{i}.json")); | ||||||
|             album_page_path = Some(json_path); |             if !json_path.exists() { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |             album_page_paths.push(json_path); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let resp: response::MusicArtist = |         let resp: response::MusicArtist = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<(MusicArtist, bool)> = |         let map_res: MapResult<(MusicArtist, Vec<String>)> = | ||||||
|             resp.map_response(&MapRespCtx::test(id)).unwrap(); |             resp.map_response(id, Language::En, None).unwrap(); | ||||||
|         let (mut artist, can_fetch_more) = map_res.c; |         let (mut artist, album_page_params) = map_res.c; | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|             "deserialization/mapping warnings: {:?}", |             "deserialization/mapping warnings: {:?}", | ||||||
|             map_res.warnings |             map_res.warnings | ||||||
|         ); |         ); | ||||||
|         assert_eq!(can_fetch_more, album_page_path.is_some()); |         assert_eq!(album_page_params.len(), album_page_paths.len()); | ||||||
| 
 | 
 | ||||||
|         // Album overview
 |         for json_path in album_page_paths { | ||||||
|         if let Some(album_page_path) = album_page_path { |             let json_file = File::open(json_path).unwrap(); | ||||||
|             let json_file = File::open(album_page_path).unwrap(); |  | ||||||
|             let resp: response::MusicArtistAlbums = |             let resp: response::MusicArtistAlbums = | ||||||
|                 serde_json::from_reader(BufReader::new(json_file)).unwrap(); |                 serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|             let map_res: MapResult<FirstAlbumPage> = |             let mut map_res: MapResult<Vec<AlbumItem>> = | ||||||
|                 resp.map_response(&MapRespCtx::test(id)).unwrap(); |                 resp.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|             assert!( |             assert!( | ||||||
|                 map_res.warnings.is_empty(), |                 map_res.warnings.is_empty(), | ||||||
|                 "deserialization/mapping warnings: {:?}", |                 "deserialization/mapping warnings: {:?}", | ||||||
|                 map_res.warnings |                 map_res.warnings | ||||||
|             ); |             ); | ||||||
|             artist.albums = map_res.c.albums; |             artist.albums.append(&mut map_res.c); | ||||||
| 
 |  | ||||||
|             // Album overview continuation
 |  | ||||||
|             for i in 2..10 { |  | ||||||
|                 let cont_path = |  | ||||||
|                     path!(*TESTFILES / "music_artist" / format!("artist_{name}_{i}.json")); |  | ||||||
|                 if !cont_path.is_file() { |  | ||||||
|                     break; |  | ||||||
|                 } |  | ||||||
|                 let json_file = File::open(cont_path).unwrap(); |  | ||||||
|                 let resp: response::MusicContinuation = |  | ||||||
|                     serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|                 let map_res: MapResult<Paginator<MusicItem>> = |  | ||||||
|                     resp.map_response(&MapRespCtx::test(id)).unwrap(); |  | ||||||
|                 assert!(!map_res.c.items.is_empty()); |  | ||||||
|                 artist.albums.extend( |  | ||||||
|                     map_res |  | ||||||
|                         .c |  | ||||||
|                         .items |  | ||||||
|                         .into_iter() |  | ||||||
|                         .filter_map(AlbumItem::from_ytm_item), |  | ||||||
|                 ); |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         insta::assert_ron_snapshot!(format!("map_music_artist_{name}"), artist); |         insta::assert_ron_snapshot!(format!("map_music_artist_{name}"), artist); | ||||||
|  | @ -510,7 +429,7 @@ mod tests { | ||||||
|         let artist: response::MusicArtist = |         let artist: response::MusicArtist = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicArtist> = artist |         let map_res: MapResult<MusicArtist> = artist | ||||||
|             .map_response(&MapRespCtx::test("UClmXPfaYhXOYsNn_QUyheWQ")) |             .map_response("UClmXPfaYhXOYsNn_QUyheWQ", Language::En, None) | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|  | @ -529,12 +448,12 @@ mod tests { | ||||||
|         let artist: response::MusicArtist = |         let artist: response::MusicArtist = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let res: Result<MapResult<MusicArtist>, ExtractionError> = |         let res: Result<MapResult<MusicArtist>, ExtractionError> = | ||||||
|             artist.map_response(&MapRespCtx::test("UCLkAepWjdylmXSltofFvsYQ")); |             artist.map_response("UCLkAepWjdylmXSltofFvsYQ", Language::En, None); | ||||||
|         let e = res.unwrap_err(); |         let e = res.unwrap_err(); | ||||||
| 
 | 
 | ||||||
|         match e { |         match e { | ||||||
|             ExtractionError::Redirect(id) => { |             ExtractionError::Redirect(id) => { | ||||||
|                 assert_eq!(id, "UCOR4_bSVIXPsGa4BbCSt60Q"); |                 assert_eq!(id, "UCOR4_bSVIXPsGa4BbCSt60Q") | ||||||
|             } |             } | ||||||
|             _ => panic!("error: {e}"), |             _ => panic!("error: {e}"), | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -11,12 +11,13 @@ use crate::{ | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     response::{self, music_item::MusicListMapper, url_endpoint::MusicPageType}, |     response::{self, music_item::MusicListMapper, url_endpoint::MusicPageType}, | ||||||
|     ClientType, MapRespCtx, MapResponse, RustyPipeQuery, |     ClientType, MapResponse, RustyPipeQuery, YTContext, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct QCharts<'a> { | struct QCharts<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     browse_id: &'a str, |     browse_id: &'a str, | ||||||
|     params: &'a str, |     params: &'a str, | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|  | @ -31,9 +32,10 @@ struct FormData { | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Get the YouTube Music charts for a given country
 |     /// Get the YouTube Music charts for a given country
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_charts(&self, country: Option<Country>) -> Result<MusicCharts, Error> { |     pub async fn music_charts(&self, country: Option<Country>) -> Result<MusicCharts, Error> { | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QCharts { |         let request_body = QCharts { | ||||||
|  |             context, | ||||||
|             browse_id: "FEmusic_charts", |             browse_id: "FEmusic_charts", | ||||||
|             params: "sgYPRkVtdXNpY19leHBsb3Jl", |             params: "sgYPRkVtdXNpY19leHBsb3Jl", | ||||||
|             form_data: country.map(|c| FormData { |             form_data: country.map(|c| FormData { | ||||||
|  | @ -53,7 +55,12 @@ impl RustyPipeQuery { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<MusicCharts> for response::MusicCharts { | impl MapResponse<MusicCharts> for response::MusicCharts { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicCharts>, ExtractionError> { |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         _id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<crate::serializer::MapResult<MusicCharts>, crate::error::ExtractionError> { | ||||||
|         let countries = self |         let countries = self | ||||||
|             .framework_updates |             .framework_updates | ||||||
|             .map(|fwu| { |             .map(|fwu| { | ||||||
|  | @ -68,9 +75,9 @@ impl MapResponse<MusicCharts> for response::MusicCharts { | ||||||
|         let mut top_playlist_id = None; |         let mut top_playlist_id = None; | ||||||
|         let mut trending_playlist_id = None; |         let mut trending_playlist_id = None; | ||||||
| 
 | 
 | ||||||
|         let mut mapper_top = MusicListMapper::new(ctx.lang); |         let mut mapper_top = MusicListMapper::new(lang); | ||||||
|         let mut mapper_trending = MusicListMapper::new(ctx.lang); |         let mut mapper_trending = MusicListMapper::new(lang); | ||||||
|         let mut mapper_other = MusicListMapper::new(ctx.lang); |         let mut mapper_other = MusicListMapper::new(lang); | ||||||
| 
 | 
 | ||||||
|         self.contents |         self.contents | ||||||
|             .single_column_browse_results_renderer |             .single_column_browse_results_renderer | ||||||
|  | @ -89,9 +96,8 @@ impl MapResponse<MusicCharts> for response::MusicCharts { | ||||||
|                         h.music_carousel_shelf_basic_header_renderer |                         h.music_carousel_shelf_basic_header_renderer | ||||||
|                             .more_content_button |                             .more_content_button | ||||||
|                             .and_then(|btn| btn.button_renderer.navigation_endpoint.music_page()) |                             .and_then(|btn| btn.button_renderer.navigation_endpoint.music_page()) | ||||||
|                             .map(|mp| (mp.typ, mp.id)) |  | ||||||
|                     }) { |                     }) { | ||||||
|                         Some((MusicPageType::Playlist { .. }, id)) => { |                         Some((MusicPageType::Playlist, id)) => { | ||||||
|                             // Top music videos (first shelf with associated playlist)
 |                             // Top music videos (first shelf with associated playlist)
 | ||||||
|                             if top_playlist_id.is_none() { |                             if top_playlist_id.is_none() { | ||||||
|                                 mapper_top.map_response(shelf.contents); |                                 mapper_top.map_response(shelf.contents); | ||||||
|  | @ -113,12 +119,12 @@ impl MapResponse<MusicCharts> for response::MusicCharts { | ||||||
|             }); |             }); | ||||||
| 
 | 
 | ||||||
|         let mapped_top = mapper_top.conv_items::<TrackItem>(); |         let mapped_top = mapper_top.conv_items::<TrackItem>(); | ||||||
|         let mapped_trending = mapper_trending.conv_items::<TrackItem>(); |         let mut mapped_trending = mapper_trending.conv_items::<TrackItem>(); | ||||||
|         let mapped_other = mapper_other.group_items(); |         let mut mapped_other = mapper_other.group_items(); | ||||||
| 
 | 
 | ||||||
|         let mut warnings = mapped_top.warnings; |         let mut warnings = mapped_top.warnings; | ||||||
|         warnings.extend(mapped_trending.warnings); |         warnings.append(&mut mapped_trending.warnings); | ||||||
|         warnings.extend(mapped_other.warnings); |         warnings.append(&mut mapped_other.warnings); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: MusicCharts { |             c: MusicCharts { | ||||||
|  | @ -142,6 +148,7 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
|  |     use crate::param::Language; | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::default("global")] |     #[case::default("global")] | ||||||
|  | @ -153,7 +160,7 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|         let charts: response::MusicCharts = |         let charts: response::MusicCharts = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicCharts> = charts.map_response(&MapRespCtx::test("")).unwrap(); |         let map_res: MapResult<MusicCharts> = charts.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
|  | @ -1,13 +1,11 @@ | ||||||
| use std::{borrow::Cow, fmt::Debug}; | use std::borrow::Cow; | ||||||
| 
 | 
 | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{ |     model::{paginator::Paginator, ArtistId, Lyrics, MusicRelated, TrackDetails, TrackItem}, | ||||||
|         paginator::{ContinuationEndpoint, Paginator}, |     param::Language, | ||||||
|         ArtistId, Lyrics, MusicRelated, TrackDetails, TrackItem, |  | ||||||
|     }, |  | ||||||
|     serializer::MapResult, |     serializer::MapResult, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -16,11 +14,12 @@ use super::{ | ||||||
|         self, |         self, | ||||||
|         music_item::{map_queue_item, MusicListMapper}, |         music_item::{map_queue_item, MusicListMapper}, | ||||||
|     }, |     }, | ||||||
|     ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery, |     ClientType, MapResponse, QBrowse, RustyPipeQuery, YTContext, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| struct QMusicDetails<'a> { | struct QMusicDetails<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     video_id: &'a str, |     video_id: &'a str, | ||||||
|     enable_persistent_playlist_panel: bool, |     enable_persistent_playlist_panel: bool, | ||||||
|     is_audio_only: bool, |     is_audio_only: bool, | ||||||
|  | @ -29,6 +28,7 @@ struct QMusicDetails<'a> { | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| struct QRadio<'a> { | struct QRadio<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     playlist_id: &'a str, |     playlist_id: &'a str, | ||||||
|     params: &'a str, |     params: &'a str, | ||||||
|     enable_persistent_playlist_panel: bool, |     enable_persistent_playlist_panel: bool, | ||||||
|  | @ -37,14 +37,12 @@ struct QRadio<'a> { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Get the metadata of a YouTube Music track
 |     /// Get the metadata of a YouTube music track
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_details<S: AsRef<str>>(&self, video_id: S) -> Result<TrackDetails, Error> { | ||||||
|     pub async fn music_details<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |  | ||||||
|         video_id: S, |  | ||||||
|     ) -> Result<TrackDetails, Error> { |  | ||||||
|         let video_id = video_id.as_ref(); |         let video_id = video_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QMusicDetails { |         let request_body = QMusicDetails { | ||||||
|  |             context, | ||||||
|             video_id, |             video_id, | ||||||
|             enable_persistent_playlist_panel: true, |             enable_persistent_playlist_panel: true, | ||||||
|             is_audio_only: true, |             is_audio_only: true, | ||||||
|  | @ -61,13 +59,14 @@ impl RustyPipeQuery { | ||||||
|         .await |         .await | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get the lyrics of a YouTube Music track
 |     /// Get the lyrics of a YouTube music track
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// The `lyrics_id` has to be obtained using [`RustyPipeQuery::music_details`].
 |     /// The `lyrics_id` has to be obtained using [`RustyPipeQuery::music_details`].
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_lyrics<S: AsRef<str>>(&self, lyrics_id: S) -> Result<Lyrics, Error> { | ||||||
|     pub async fn music_lyrics<S: AsRef<str> + Debug>(&self, lyrics_id: S) -> Result<Lyrics, Error> { |  | ||||||
|         let lyrics_id = lyrics_id.as_ref(); |         let lyrics_id = lyrics_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: lyrics_id, |             browse_id: lyrics_id, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -84,13 +83,11 @@ impl RustyPipeQuery { | ||||||
|     /// Get related items (tracks, playlists, artists) to a YouTube Music track
 |     /// Get related items (tracks, playlists, artists) to a YouTube Music track
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// The `related_id` has to be obtained using [`RustyPipeQuery::music_details`].
 |     /// The `related_id` has to be obtained using [`RustyPipeQuery::music_details`].
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_related<S: AsRef<str>>(&self, related_id: S) -> Result<MusicRelated, Error> { | ||||||
|     pub async fn music_related<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |  | ||||||
|         related_id: S, |  | ||||||
|     ) -> Result<MusicRelated, Error> { |  | ||||||
|         let related_id = related_id.as_ref(); |         let related_id = related_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: related_id, |             browse_id: related_id, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -107,13 +104,17 @@ impl RustyPipeQuery { | ||||||
|     /// Get a YouTube Music radio (a dynamically generated playlist)
 |     /// Get a YouTube Music radio (a dynamically generated playlist)
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// The `radio_id` can be obtained using [`RustyPipeQuery::music_artist`] to get an artist's radio.
 |     /// The `radio_id` can be obtained using [`RustyPipeQuery::music_artist`] to get an artist's radio.
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_radio<S: AsRef<str>>( | ||||||
|     pub async fn music_radio<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         radio_id: S, |         radio_id: S, | ||||||
|     ) -> Result<Paginator<TrackItem>, Error> { |     ) -> Result<Paginator<TrackItem>, Error> { | ||||||
|         let radio_id = radio_id.as_ref(); |         let radio_id = radio_id.as_ref(); | ||||||
|  |         let visitor_data = self.get_ytm_visitor_data().await?; | ||||||
|  |         let context = self | ||||||
|  |             .get_context(ClientType::DesktopMusic, true, Some(&visitor_data)) | ||||||
|  |             .await; | ||||||
|         let request_body = QRadio { |         let request_body = QRadio { | ||||||
|  |             context, | ||||||
|             playlist_id: radio_id, |             playlist_id: radio_id, | ||||||
|             params: "wAEB8gECeAE%3D", |             params: "wAEB8gECeAE%3D", | ||||||
|             enable_persistent_playlist_panel: true, |             enable_persistent_playlist_panel: true, | ||||||
|  | @ -132,8 +133,7 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get a YouTube Music radio (a dynamically generated playlist) for a track
 |     /// Get a YouTube Music radio (a dynamically generated playlist) for a track
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_radio_track<S: AsRef<str>>( | ||||||
|     pub async fn music_radio_track<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         video_id: S, |         video_id: S, | ||||||
|     ) -> Result<Paginator<TrackItem>, Error> { |     ) -> Result<Paginator<TrackItem>, Error> { | ||||||
|  | @ -142,8 +142,7 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get a YouTube Music radio (a dynamically generated playlist) for a playlist
 |     /// Get a YouTube Music radio (a dynamically generated playlist) for a playlist
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_radio_playlist<S: AsRef<str>>( | ||||||
|     pub async fn music_radio_playlist<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         playlist_id: S, |         playlist_id: S, | ||||||
|     ) -> Result<Paginator<TrackItem>, Error> { |     ) -> Result<Paginator<TrackItem>, Error> { | ||||||
|  | @ -155,7 +154,9 @@ impl RustyPipeQuery { | ||||||
| impl MapResponse<TrackDetails> for response::MusicDetails { | impl MapResponse<TrackDetails> for response::MusicDetails { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         id: &str, | ||||||
|  |         lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<TrackDetails>, ExtractionError> { |     ) -> Result<MapResult<TrackDetails>, ExtractionError> { | ||||||
|         let tabs = self |         let tabs = self | ||||||
|             .contents |             .contents | ||||||
|  | @ -192,10 +193,9 @@ impl MapResponse<TrackDetails> for response::MusicDetails { | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let content = content.ok_or_else(|| ExtractionError::NotFound { |         let content = content.ok_or(ExtractionError::ContentUnavailable(Cow::Borrowed( | ||||||
|             id: ctx.id.to_owned(), |             "track not found", | ||||||
|             msg: "no content".into(), |         )))?; | ||||||
|         })?; |  | ||||||
|         let track_item = content |         let track_item = content | ||||||
|             .contents |             .contents | ||||||
|             .c |             .c | ||||||
|  | @ -207,18 +207,22 @@ impl MapResponse<TrackDetails> for response::MusicDetails { | ||||||
|                 response::music_item::PlaylistPanelVideo::None => None, |                 response::music_item::PlaylistPanelVideo::None => None, | ||||||
|             }) |             }) | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no video item")))?; |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no video item")))?; | ||||||
|         let mut track = map_queue_item(track_item, ctx.lang); |         let track = map_queue_item(track_item, lang); | ||||||
| 
 | 
 | ||||||
|         let mut warnings = content.contents.warnings; |         if track.id != id { | ||||||
|         warnings.append(&mut track.warnings); |             return Err(ExtractionError::WrongResult(format!( | ||||||
|  |                 "got wrong video id {}, expected {}", | ||||||
|  |                 track.id, id | ||||||
|  |             ))); | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: TrackDetails { |             c: TrackDetails { | ||||||
|                 track: track.c, |                 track, | ||||||
|                 lyrics_id, |                 lyrics_id, | ||||||
|                 related_id, |                 related_id, | ||||||
|             }, |             }, | ||||||
|             warnings, |             warnings: content.contents.warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -226,7 +230,9 @@ impl MapResponse<TrackDetails> for response::MusicDetails { | ||||||
| impl MapResponse<Paginator<TrackItem>> for response::MusicDetails { | impl MapResponse<Paginator<TrackItem>> for response::MusicDetails { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|  |         lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> { |     ) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> { | ||||||
|         let tabs = self |         let tabs = self | ||||||
|             .contents |             .contents | ||||||
|  | @ -238,25 +244,20 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails { | ||||||
|         let content = tabs |         let content = tabs | ||||||
|             .into_iter() |             .into_iter() | ||||||
|             .find_map(|t| t.tab_renderer.content) |             .find_map(|t| t.tab_renderer.content) | ||||||
|             .ok_or_else(|| ExtractionError::NotFound { |             .ok_or(ExtractionError::ContentUnavailable(Cow::Borrowed( | ||||||
|                 id: ctx.id.to_owned(), |                 "radio unavailable", | ||||||
|                 msg: "no content".into(), |             )))? | ||||||
|             })? |  | ||||||
|             .music_queue_renderer |             .music_queue_renderer | ||||||
|             .content |             .content | ||||||
|             .playlist_panel_renderer; |             .playlist_panel_renderer; | ||||||
| 
 | 
 | ||||||
|         let mut warnings = content.contents.warnings; |  | ||||||
| 
 |  | ||||||
|         let tracks = content |         let tracks = content | ||||||
|             .contents |             .contents | ||||||
|             .c |             .c | ||||||
|             .into_iter() |             .into_iter() | ||||||
|             .filter_map(|item| match item { |             .filter_map(|item| match item { | ||||||
|                 response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) => { |                 response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) => { | ||||||
|                     let mut track = map_queue_item(item, ctx.lang); |                     Some(map_queue_item(item, lang)) | ||||||
|                     warnings.append(&mut track.warnings); |  | ||||||
|                     Some(track.c) |  | ||||||
|                 } |                 } | ||||||
|                 response::music_item::PlaylistPanelVideo::None => None, |                 response::music_item::PlaylistPanelVideo::None => None, | ||||||
|             }) |             }) | ||||||
|  | @ -274,26 +275,32 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails { | ||||||
|                 tracks, |                 tracks, | ||||||
|                 ctoken, |                 ctoken, | ||||||
|                 None, |                 None, | ||||||
|                 ContinuationEndpoint::MusicNext, |                 crate::model::paginator::ContinuationEndpoint::MusicNext, | ||||||
|                 false, |  | ||||||
|             ), |             ), | ||||||
|             warnings, |             warnings: content.contents.warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<Lyrics> for response::MusicLyrics { | impl MapResponse<Lyrics> for response::MusicLyrics { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<Lyrics>, ExtractionError> { |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         _id: &str, | ||||||
|  |         _lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<Lyrics>, ExtractionError> { | ||||||
|         let lyrics = self |         let lyrics = self | ||||||
|             .contents |             .contents | ||||||
|             .into_res() |             .section_list_renderer | ||||||
|             .map_err(|msg| ExtractionError::NotFound { |             .and_then(|sl| { | ||||||
|                 id: ctx.id.to_owned(), |                 sl.contents | ||||||
|                 msg: msg.into(), |  | ||||||
|             })? |  | ||||||
|                     .into_iter() |                     .into_iter() | ||||||
|                     .find_map(|item| item.music_description_shelf_renderer) |                     .find_map(|item| item.music_description_shelf_renderer) | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?; |             }) | ||||||
|  |             .ok_or(match self.contents.message_renderer { | ||||||
|  |                 Some(msg) => ExtractionError::ContentUnavailable(Cow::Owned(msg.text)), | ||||||
|  |                 None => ExtractionError::InvalidData(Cow::Borrowed("no content")), | ||||||
|  |             })?; | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: Lyrics { |             c: Lyrics { | ||||||
|  | @ -308,18 +315,17 @@ impl MapResponse<Lyrics> for response::MusicLyrics { | ||||||
| impl MapResponse<MusicRelated> for response::MusicRelated { | impl MapResponse<MusicRelated> for response::MusicRelated { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|  |         lang: Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<MusicRelated>, ExtractionError> { |     ) -> Result<MapResult<MusicRelated>, ExtractionError> { | ||||||
|         let contents = self |  | ||||||
|             .contents |  | ||||||
|             .into_res() |  | ||||||
|             .map_err(|msg| ExtractionError::NotFound { |  | ||||||
|                 id: ctx.id.to_owned(), |  | ||||||
|                 msg: msg.into(), |  | ||||||
|             })?; |  | ||||||
| 
 |  | ||||||
|         // Find artist
 |         // Find artist
 | ||||||
|         let artist_id = contents.iter().find_map(|section| match section { |         let artist_id = self | ||||||
|  |             .contents | ||||||
|  |             .section_list_renderer | ||||||
|  |             .contents | ||||||
|  |             .iter() | ||||||
|  |             .find_map(|section| match section { | ||||||
|                 response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { |                 response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { | ||||||
|                     shelf.header.as_ref().and_then(|h| { |                     shelf.header.as_ref().and_then(|h| { | ||||||
|                         h.music_carousel_shelf_basic_header_renderer |                         h.music_carousel_shelf_basic_header_renderer | ||||||
|  | @ -339,13 +345,13 @@ impl MapResponse<MusicRelated> for response::MusicRelated { | ||||||
|                 _ => None, |                 _ => None, | ||||||
|             }); |             }); | ||||||
| 
 | 
 | ||||||
|         let mut mapper_tracks = MusicListMapper::new(ctx.lang); |         let mut mapper_tracks = MusicListMapper::new(lang); | ||||||
|         let mut mapper = match artist_id { |         let mut mapper = match artist_id { | ||||||
|             Some(artist_id) => MusicListMapper::with_artist(ctx.lang, artist_id), |             Some(artist_id) => MusicListMapper::with_artist(lang, artist_id), | ||||||
|             None => MusicListMapper::new(ctx.lang), |             None => MusicListMapper::new(lang), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let mut sections = contents.into_iter(); |         let mut sections = self.contents.section_list_renderer.contents.into_iter(); | ||||||
|         if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf)) = |         if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf)) = | ||||||
|             sections.next() |             sections.next() | ||||||
|         { |         { | ||||||
|  | @ -389,7 +395,7 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
|     use crate::{model, util::tests::TESTFILES}; |     use crate::{model, param::Language, util::tests::TESTFILES}; | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::mv("mv", "ZeerrnuLi5E")] |     #[case::mv("mv", "ZeerrnuLi5E")] | ||||||
|  | @ -401,7 +407,7 @@ mod tests { | ||||||
|         let details: response::MusicDetails = |         let details: response::MusicDetails = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<model::TrackDetails> = |         let map_res: MapResult<model::TrackDetails> = | ||||||
|             details.map_response(&MapRespCtx::test(id)).unwrap(); |             details.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -421,7 +427,7 @@ mod tests { | ||||||
|         let radio: response::MusicDetails = |         let radio: response::MusicDetails = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Paginator<TrackItem>> = |         let map_res: MapResult<Paginator<TrackItem>> = | ||||||
|             radio.map_response(&MapRespCtx::test(id)).unwrap(); |             radio.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -438,7 +444,7 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|         let lyrics: response::MusicLyrics = |         let lyrics: response::MusicLyrics = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Lyrics> = lyrics.map_response(&MapRespCtx::test("")).unwrap(); |         let map_res: MapResult<Lyrics> = lyrics.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -455,7 +461,7 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|         let lyrics: response::MusicRelated = |         let lyrics: response::MusicRelated = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicRelated> = lyrics.map_response(&MapRespCtx::test("")).unwrap(); |         let map_res: MapResult<MusicRelated> = lyrics.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| use std::{borrow::Cow, fmt::Debug}; | use std::borrow::Cow; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|  | @ -7,15 +7,16 @@ use crate::{ | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     response::{self, music_item::MusicListMapper, url_endpoint::NavigationEndpoint}, |     response::{self, music_item::MusicListMapper}, | ||||||
|     ClientType, MapRespCtx, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery, |     ClientType, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Get a list of moods and genres from YouTube Music
 |     /// Get a list of moods and genres from YouTube Music
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_genres(&self) -> Result<Vec<MusicGenreItem>, Error> { |     pub async fn music_genres(&self) -> Result<Vec<MusicGenreItem>, Error> { | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: "FEmusic_moods_and_genres", |             browse_id: "FEmusic_moods_and_genres", | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -30,13 +31,11 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get the playlists from a YouTube Music genre
 |     /// Get the playlists from a YouTube Music genre
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_genre<S: AsRef<str>>(&self, genre_id: S) -> Result<MusicGenre, Error> { | ||||||
|     pub async fn music_genre<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |  | ||||||
|         genre_id: S, |  | ||||||
|     ) -> Result<MusicGenre, Error> { |  | ||||||
|         let genre_id = genre_id.as_ref(); |         let genre_id = genre_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowseParams { |         let request_body = QBrowseParams { | ||||||
|  |             context, | ||||||
|             browse_id: "FEmusic_moods_and_genres_category", |             browse_id: "FEmusic_moods_and_genres_category", | ||||||
|             params: genre_id, |             params: genre_id, | ||||||
|         }; |         }; | ||||||
|  | @ -55,8 +54,10 @@ impl RustyPipeQuery { | ||||||
| impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres { | impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         _ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|     ) -> Result<MapResult<Vec<MusicGenreItem>>, ExtractionError> { |         _lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<crate::serializer::MapResult<Vec<MusicGenreItem>>, ExtractionError> { | ||||||
|         let content = self |         let content = self | ||||||
|             .contents |             .contents | ||||||
|             .single_column_browse_results_renderer |             .single_column_browse_results_renderer | ||||||
|  | @ -80,7 +81,7 @@ impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres { | ||||||
|         let genres = content_iter |         let genres = content_iter | ||||||
|             .enumerate() |             .enumerate() | ||||||
|             .flat_map(|(i, grid)| { |             .flat_map(|(i, grid)| { | ||||||
|                 let mut grid = grid.grid_renderer.contents; |                 let mut grid = grid.grid_renderer.items; | ||||||
|                 warnings.append(&mut grid.warnings); |                 warnings.append(&mut grid.warnings); | ||||||
|                 grid.c.into_iter().filter_map(move |section| match section { |                 grid.c.into_iter().filter_map(move |section| match section { | ||||||
|                     response::music_genres::NavigationButton::MusicNavigationButtonRenderer( |                     response::music_genres::NavigationButton::MusicNavigationButtonRenderer( | ||||||
|  | @ -104,7 +105,14 @@ impl MapResponse<Vec<MusicGenreItem>> for response::MusicGenres { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<MusicGenre> for response::MusicGenre { | impl MapResponse<MusicGenre> for response::MusicGenre { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicGenre>, ExtractionError> { |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<crate::serializer::MapResult<MusicGenre>, ExtractionError> { | ||||||
|  |         // dbg!(&self);
 | ||||||
|  | 
 | ||||||
|         let content = self |         let content = self | ||||||
|             .contents |             .contents | ||||||
|             .single_column_browse_results_renderer |             .single_column_browse_results_renderer | ||||||
|  | @ -136,21 +144,19 @@ impl MapResponse<MusicGenre> for response::MusicGenre { | ||||||
|                             h.music_carousel_shelf_basic_header_renderer |                             h.music_carousel_shelf_basic_header_renderer | ||||||
|                                 .more_content_button |                                 .more_content_button | ||||||
|                                 .and_then(|btn| { |                                 .and_then(|btn| { | ||||||
|                                     if let NavigationEndpoint::Browse { |                                     btn.button_renderer | ||||||
|                                         browse_endpoint, .. |                                         .navigation_endpoint | ||||||
|                                     } = btn.button_renderer.navigation_endpoint |                                         .browse_endpoint | ||||||
|                                     { |                                         .and_then(|browse| { | ||||||
|                                         if browse_endpoint.browse_id |                                             if browse.browse_id | ||||||
|                                                 == "FEmusic_moods_and_genres_category" |                                                 == "FEmusic_moods_and_genres_category" | ||||||
|                                             { |                                             { | ||||||
|                                             Some(browse_endpoint.params) |                                                 Some(browse.params) | ||||||
|                                         } else { |  | ||||||
|                                             None |  | ||||||
|                                         } |  | ||||||
|                                             } else { |                                             } else { | ||||||
|                                                 None |                                                 None | ||||||
|                                             } |                                             } | ||||||
|                                         }) |                                         }) | ||||||
|  |                                 }) | ||||||
|                         }), |                         }), | ||||||
|                         shelf.contents, |                         shelf.contents, | ||||||
|                     ), |                     ), | ||||||
|  | @ -164,7 +170,7 @@ impl MapResponse<MusicGenre> for response::MusicGenre { | ||||||
|                     _ => return None, |                     _ => return None, | ||||||
|                 }; |                 }; | ||||||
| 
 | 
 | ||||||
|                 let mut mapper = MusicListMapper::new(ctx.lang); |                 let mut mapper = MusicListMapper::new(lang); | ||||||
|                 mapper.map_response(items); |                 mapper.map_response(items); | ||||||
|                 let mut mapped = mapper.conv_items(); |                 let mut mapped = mapper.conv_items(); | ||||||
|                 warnings.append(&mut mapped.warnings); |                 warnings.append(&mut mapped.warnings); | ||||||
|  | @ -179,7 +185,7 @@ impl MapResponse<MusicGenre> for response::MusicGenre { | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: MusicGenre { |             c: MusicGenre { | ||||||
|                 id: ctx.id.to_owned(), |                 id: id.to_owned(), | ||||||
|                 name: self.header.music_header_renderer.title, |                 name: self.header.music_header_renderer.title, | ||||||
|                 sections, |                 sections, | ||||||
|             }, |             }, | ||||||
|  | @ -196,7 +202,7 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
|     use crate::{model, util::tests::TESTFILES}; |     use crate::{model, param::Language, util::tests::TESTFILES}; | ||||||
| 
 | 
 | ||||||
|     #[test] |     #[test] | ||||||
|     fn map_music_genres() { |     fn map_music_genres() { | ||||||
|  | @ -206,7 +212,7 @@ mod tests { | ||||||
|         let playlist: response::MusicGenres = |         let playlist: response::MusicGenres = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Vec<model::MusicGenreItem>> = |         let map_res: MapResult<Vec<model::MusicGenreItem>> = | ||||||
|             playlist.map_response(&MapRespCtx::test("")).unwrap(); |             playlist.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -226,7 +232,7 @@ mod tests { | ||||||
|         let playlist: response::MusicGenre = |         let playlist: response::MusicGenre = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<model::MusicGenre> = |         let map_res: MapResult<model::MusicGenre> = | ||||||
|             playlist.map_response(&MapRespCtx::test(id)).unwrap(); |             playlist.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
|  | @ -4,16 +4,16 @@ use crate::{ | ||||||
|     client::response::music_item::MusicListMapper, |     client::response::music_item::MusicListMapper, | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{traits::FromYtItem, AlbumItem, TrackItem}, |     model::{traits::FromYtItem, AlbumItem, TrackItem}, | ||||||
|     serializer::MapResult, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{response, ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery}; | use super::{response, ClientType, MapResponse, QBrowse, RustyPipeQuery}; | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Get the new albums that were released on YouTube Music
 |     /// Get the new albums that were released on YouTube Music
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_new_albums(&self) -> Result<Vec<AlbumItem>, Error> { |     pub async fn music_new_albums(&self) -> Result<Vec<AlbumItem>, Error> { | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: "FEmusic_new_releases_albums", |             browse_id: "FEmusic_new_releases_albums", | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -28,9 +28,10 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get the new music videos that were released on YouTube Music
 |     /// Get the new music videos that were released on YouTube Music
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_new_videos(&self) -> Result<Vec<TrackItem>, Error> { |     pub async fn music_new_videos(&self) -> Result<Vec<TrackItem>, Error> { | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: "FEmusic_new_releases_videos", |             browse_id: "FEmusic_new_releases_videos", | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -46,7 +47,12 @@ impl RustyPipeQuery { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl<T: FromYtItem> MapResponse<Vec<T>> for response::MusicNew { | impl<T: FromYtItem> MapResponse<Vec<T>> for response::MusicNew { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<Vec<T>>, ExtractionError> { |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         _id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<crate::serializer::MapResult<Vec<T>>, ExtractionError> { | ||||||
|         let items = self |         let items = self | ||||||
|             .contents |             .contents | ||||||
|             .single_column_browse_results_renderer |             .single_column_browse_results_renderer | ||||||
|  | @ -64,7 +70,7 @@ impl<T: FromYtItem> MapResponse<Vec<T>> for response::MusicNew { | ||||||
|             .grid_renderer |             .grid_renderer | ||||||
|             .items; |             .items; | ||||||
| 
 | 
 | ||||||
|         let mut mapper = MusicListMapper::new(ctx.lang); |         let mut mapper = MusicListMapper::new(lang); | ||||||
|         mapper.map_response(items); |         mapper.map_response(items); | ||||||
| 
 | 
 | ||||||
|         Ok(mapper.conv_items()) |         Ok(mapper.conv_items()) | ||||||
|  | @ -79,7 +85,7 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
|     use crate::{serializer::MapResult, util::tests::TESTFILES}; |     use crate::{param::Language, serializer::MapResult, util::tests::TESTFILES}; | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::default("default")] |     #[case::default("default")] | ||||||
|  | @ -90,7 +96,7 @@ mod tests { | ||||||
|         let new_albums: response::MusicNew = |         let new_albums: response::MusicNew = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Vec<AlbumItem>> = |         let map_res: MapResult<Vec<AlbumItem>> = | ||||||
|             new_albums.map_response(&MapRespCtx::test("")).unwrap(); |             new_albums.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -102,15 +108,14 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::default("default")] |     #[case::default("default")] | ||||||
|     #[case::default("w_podcasts")] |  | ||||||
|     fn map_music_new_videos(#[case] name: &str) { |     fn map_music_new_videos(#[case] name: &str) { | ||||||
|         let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json")); |         let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json")); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let new_videos: response::MusicNew = |         let new_albums: response::MusicNew = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Vec<TrackItem>> = |         let map_res: MapResult<Vec<TrackItem>> = | ||||||
|             new_videos.map_response(&MapRespCtx::test("")).unwrap(); |             new_albums.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
|  | @ -1,36 +1,30 @@ | ||||||
| use std::{borrow::Cow, fmt::Debug}; | use std::borrow::Cow; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     client::response::url_endpoint::NavigationEndpoint, |  | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{ |     model::{paginator::Paginator, AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem}, | ||||||
|         paginator::{ContinuationEndpoint, Paginator}, |     serializer::MapResult, | ||||||
|         richtext::RichText, |     util::{self, TryRemove}, | ||||||
|         AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem, TrackType, |  | ||||||
|     }, |  | ||||||
|     serializer::{text::TextComponents, MapResult}, |  | ||||||
|     util::{self, dictionary, TryRemove, DOT_SEPARATOR}, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use self::response::url_endpoint::MusicPageType; |  | ||||||
| 
 |  | ||||||
| use super::{ | use super::{ | ||||||
|     response::{ |     response::{ | ||||||
|         self, |         self, | ||||||
|         music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper}, |         music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper}, | ||||||
|     }, |     }, | ||||||
|     ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery, |     ClientType, MapResponse, QBrowse, RustyPipeQuery, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Get a playlist from YouTube Music
 |     /// Get a playlist from YouTube Music
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_playlist<S: AsRef<str>>( | ||||||
|     pub async fn music_playlist<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         playlist_id: S, |         playlist_id: S, | ||||||
|     ) -> Result<MusicPlaylist, Error> { |     ) -> Result<MusicPlaylist, Error> { | ||||||
|         let playlist_id = playlist_id.as_ref(); |         let playlist_id = playlist_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: &format!("VL{playlist_id}"), |             browse_id: &format!("VL{playlist_id}"), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -45,13 +39,11 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get an album from YouTube Music
 |     /// Get an album from YouTube Music
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_album<S: AsRef<str>>(&self, album_id: S) -> Result<MusicAlbum, Error> { | ||||||
|     pub async fn music_album<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |  | ||||||
|         album_id: S, |  | ||||||
|     ) -> Result<MusicAlbum, Error> { |  | ||||||
|         let album_id = album_id.as_ref(); |         let album_id = album_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: album_id, |             browse_id: album_id, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -68,7 +60,7 @@ impl RustyPipeQuery { | ||||||
|         // In rare cases, albums may have track numbers =0 (example: MPREb_RM0QfZ0eSKL)
 |         // In rare cases, albums may have track numbers =0 (example: MPREb_RM0QfZ0eSKL)
 | ||||||
|         // They should be replaced with the track number derived from the previous track.
 |         // They should be replaced with the track number derived from the previous track.
 | ||||||
|         let mut n_prev = 0; |         let mut n_prev = 0; | ||||||
|         for track in &mut album.tracks { |         for track in album.tracks.iter_mut() { | ||||||
|             let tn = track.track_nr.unwrap_or_default(); |             let tn = track.track_nr.unwrap_or_default(); | ||||||
|             if tn == 0 { |             if tn == 0 { | ||||||
|                 n_prev += 1; |                 n_prev += 1; | ||||||
|  | @ -87,63 +79,35 @@ impl RustyPipeQuery { | ||||||
|                 .iter() |                 .iter() | ||||||
|                 .enumerate() |                 .enumerate() | ||||||
|                 .filter_map(|(i, track)| { |                 .filter_map(|(i, track)| { | ||||||
|                     if track.track_type.is_video() && !track.unavailable { |                     if track.is_video { | ||||||
|                         Some((i, track.name.clone())) |                         Some((i, track.name.to_owned())) | ||||||
|                     } else { |                     } else { | ||||||
|                         None |                         None | ||||||
|                     } |                     } | ||||||
|                 }) |                 }) | ||||||
|                 .collect::<Vec<_>>(); |                 .collect::<Vec<_>>(); | ||||||
| 
 | 
 | ||||||
|             let last_tn = album |             if !to_replace.is_empty() { | ||||||
|                 .tracks |  | ||||||
|                 .last() |  | ||||||
|                 .and_then(|t| t.track_nr) |  | ||||||
|                 .unwrap_or_default(); |  | ||||||
|             if !to_replace.is_empty() || last_tn < album.track_count { |  | ||||||
|                 tracing::debug!( |  | ||||||
|                     "fetching album playlist ({} tracks, {} to replace)", |  | ||||||
|                     album.track_count, |  | ||||||
|                     to_replace.len() |  | ||||||
|                 ); |  | ||||||
|                 let mut playlist = self.music_playlist(playlist_id).await?; |                 let mut playlist = self.music_playlist(playlist_id).await?; | ||||||
|                 playlist |                 playlist | ||||||
|                     .tracks |                     .tracks | ||||||
|                     .extend_limit(&self, album.track_count.into()) |                     .extend_limit(&self, album.tracks.len()) | ||||||
|                     .await?; |                     .await?; | ||||||
| 
 | 
 | ||||||
|                 for (i, title) in to_replace { |                 for (i, title) in to_replace { | ||||||
|                     let found_track = playlist.tracks.items.iter().find_map(|track| { |                     let found_track = playlist.tracks.items.iter().find_map(|track| { | ||||||
|                         if track.name == title && track.track_type.is_track() { |                         if track.name == title && !track.is_video { | ||||||
|                             Some((track.id.clone(), track.duration, track.unavailable)) |                             Some((track.id.to_owned(), track.duration)) | ||||||
|                         } else { |                         } else { | ||||||
|                             None |                             None | ||||||
|                         } |                         } | ||||||
|                     }); |                     }); | ||||||
|                     if let Some((track_id, duration, unavailable)) = found_track { |                     if let Some((track_id, duration)) = found_track { | ||||||
|                         album.tracks[i].id = track_id; |                         album.tracks[i].id = track_id; | ||||||
|                         if let Some(duration) = duration { |                         if let Some(duration) = duration { | ||||||
|                             album.tracks[i].duration = Some(duration); |                             album.tracks[i].duration = Some(duration); | ||||||
|                         } |                         } | ||||||
|                         album.tracks[i].track_type = TrackType::Track; |                         album.tracks[i].is_video = false; | ||||||
|                         album.tracks[i].unavailable = unavailable; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 // Extend the list of album tracks with the ones from the playlist if the playlist returned more tracks
 |  | ||||||
|                 // This is the case for albums with more than 200 tracks (e.g. audiobooks)
 |  | ||||||
|                 // Note: in some cases the playlist may contain a loop of repeating tracks. If a track was found in the playlist
 |  | ||||||
|                 // that already exists in the album, stop.
 |  | ||||||
|                 if album.tracks.len() < playlist.tracks.items.len() { |  | ||||||
|                     let mut tn = last_tn; |  | ||||||
|                     for mut t in playlist.tracks.items.into_iter().skip(album.tracks.len()) { |  | ||||||
|                         if album.tracks.iter().any(|at| at.id == t.id) { |  | ||||||
|                             break; |  | ||||||
|                         } |  | ||||||
|                         tn += 1; |  | ||||||
|                         t.album = album.tracks.first().and_then(|t| t.album.clone()); |  | ||||||
|                         t.track_nr = Some(tn); |  | ||||||
|                         album.tracks.push(t); |  | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  | @ -155,52 +119,20 @@ impl RustyPipeQuery { | ||||||
| impl MapResponse<MusicPlaylist> for response::MusicPlaylist { | impl MapResponse<MusicPlaylist> for response::MusicPlaylist { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<MusicPlaylist>, ExtractionError> { |     ) -> Result<MapResult<MusicPlaylist>, ExtractionError> { | ||||||
|         let contents = match self.contents { |         // dbg!(&self);
 | ||||||
|             Some(c) => c, |  | ||||||
|             None => { |  | ||||||
|                 if self.microformat.microformat_data_renderer.noindex { |  | ||||||
|                     return Err(ExtractionError::NotFound { |  | ||||||
|                         id: ctx.id.to_owned(), |  | ||||||
|                         msg: "no contents".into(), |  | ||||||
|                     }); |  | ||||||
|                 } else { |  | ||||||
|                     return Err(ExtractionError::InvalidData("no contents".into())); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 | 
 | ||||||
|         let (header, music_contents) = match contents { |         let mut content = self.contents.single_column_browse_results_renderer.contents; | ||||||
|             response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => ( |         let mut music_contents = content | ||||||
|                 self.header, |             .try_swap_remove(0) | ||||||
|                 c.contents |  | ||||||
|                     .into_iter() |  | ||||||
|                     .next() |  | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? | ||||||
|             .tab_renderer |             .tab_renderer | ||||||
|             .content |             .content | ||||||
|                     .section_list_renderer, |             .section_list_renderer; | ||||||
|             ), |         let mut shelf = music_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, |  | ||||||
|             ), |  | ||||||
|         }; |  | ||||||
|         let shelf = music_contents |  | ||||||
|             .contents |             .contents | ||||||
|             .into_iter() |             .into_iter() | ||||||
|             .find_map(|section| match section { |             .find_map(|section| match section { | ||||||
|  | @ -212,98 +144,66 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist { | ||||||
|             )))?; |             )))?; | ||||||
| 
 | 
 | ||||||
|         if let Some(playlist_id) = shelf.playlist_id { |         if let Some(playlist_id) = shelf.playlist_id { | ||||||
|             if playlist_id != ctx.id { |             if playlist_id != id { | ||||||
|                 return Err(ExtractionError::WrongResult(format!( |                 return Err(ExtractionError::WrongResult(format!( | ||||||
|                     "got wrong playlist id {}, expected {}", |                     "got wrong playlist id {playlist_id}, expected {id}" | ||||||
|                     playlist_id, ctx.id |  | ||||||
|                 ))); |                 ))); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let mut mapper = MusicListMapper::new(ctx.lang); |         let mut mapper = MusicListMapper::new(lang); | ||||||
|         mapper.map_response(shelf.contents); |         mapper.map_response(shelf.contents); | ||||||
| 
 |  | ||||||
|         let ctoken = mapper.ctoken.clone().or_else(|| { |  | ||||||
|             shelf |  | ||||||
|                 .continuations |  | ||||||
|                 .into_iter() |  | ||||||
|                 .next() |  | ||||||
|                 .map(|cont| cont.next_continuation_data.continuation) |  | ||||||
|         }); |  | ||||||
|         let map_res = mapper.conv_items(); |         let map_res = mapper.conv_items(); | ||||||
| 
 | 
 | ||||||
|         let track_count = if ctoken.is_some() { |         let ctoken = shelf | ||||||
|             header.as_ref().and_then(|h| { |             .continuations | ||||||
|                 let parts = h |             .try_swap_remove(0) | ||||||
|                     .music_detail_header_renderer |             .map(|cont| cont.next_continuation_data.continuation); | ||||||
|  | 
 | ||||||
|  |         let track_count = match ctoken { | ||||||
|  |             Some(_) => self.header.as_ref().and_then(|h| { | ||||||
|  |                 h.music_detail_header_renderer | ||||||
|                     .second_subtitle |                     .second_subtitle | ||||||
|                     .split(|p| p == DOT_SEPARATOR) |                     .first() | ||||||
|                     .collect::<Vec<_>>(); |                     .and_then(|txt| util::parse_numeric::<u64>(txt).ok()) | ||||||
|                 parts |             }), | ||||||
|                     .get(usize::from(parts.len() > 2)) |             None => Some(map_res.c.len() as u64), | ||||||
|                     .and_then(|txt| util::parse_numeric::<u64>(&txt[0]).ok()) |  | ||||||
|             }) |  | ||||||
|         } else { |  | ||||||
|             Some(map_res.c.len() as u64) |  | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let related_ctoken = music_contents |         let related_ctoken = music_contents | ||||||
|             .continuations |             .continuations | ||||||
|             .into_iter() |             .try_swap_remove(0) | ||||||
|             .next() |  | ||||||
|             .map(|c| c.next_continuation_data.continuation); |             .map(|c| c.next_continuation_data.continuation); | ||||||
| 
 | 
 | ||||||
|         let (from_ytm, channel, name, thumbnail, description) = match header { |         let (from_ytm, channel, name, thumbnail, description) = match self.header { | ||||||
|             Some(header) => { |             Some(header) => { | ||||||
|                 let h = header.music_detail_header_renderer; |                 let h = header.music_detail_header_renderer; | ||||||
| 
 | 
 | ||||||
|                 let (from_ytm, channel) = match h.facepile { |                 let from_ytm = h | ||||||
|                     Some(facepile) => { |                     .subtitle | ||||||
|                         let from_ytm = facepile.avatar_stack_view_model.text.starts_with("YouTube"); |                     .0 | ||||||
|                         let channel = facepile |                     .iter() | ||||||
|                             .avatar_stack_view_model |                     .any(|c| c.as_str() == util::YT_MUSIC_NAME); | ||||||
|                             .renderer_context |                 let channel = h | ||||||
|                             .command_context |                     .subtitle | ||||||
|                             .and_then(|c| { |                     .0 | ||||||
|                                 c.on_tap |                     .into_iter() | ||||||
|                                     .innertube_command |                     .find_map(|c| ChannelId::try_from(c).ok()); | ||||||
|                                     .music_page() |  | ||||||
|                                     .filter(|p| p.typ == MusicPageType::User) |  | ||||||
|                                     .map(|p| p.id) |  | ||||||
|                             }) |  | ||||||
|                             .map(|id| ChannelId { |  | ||||||
|                                 id, |  | ||||||
|                                 name: facepile.avatar_stack_view_model.text, |  | ||||||
|                             }); |  | ||||||
| 
 |  | ||||||
|                         (from_ytm && channel.is_none(), channel) |  | ||||||
|                     } |  | ||||||
|                     None => { |  | ||||||
|                         let st = match h.strapline_text_one { |  | ||||||
|                             Some(s) => s, |  | ||||||
|                             None => h.subtitle, |  | ||||||
|                         }; |  | ||||||
| 
 |  | ||||||
|                         let from_ytm = st.0.iter().any(util::is_ytm); |  | ||||||
|                         let channel = st.0.into_iter().find_map(|c| ChannelId::try_from(c).ok()); |  | ||||||
|                         (from_ytm, channel) |  | ||||||
|                     } |  | ||||||
|                 }; |  | ||||||
| 
 | 
 | ||||||
|                 ( |                 ( | ||||||
|                     from_ytm, |                     from_ytm, | ||||||
|                     channel, |                     channel, | ||||||
|                     h.title, |                     h.title, | ||||||
|                     h.thumbnail.into(), |                     h.thumbnail.into(), | ||||||
|                     h.description.map(TextComponents::from), |                     h.description, | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|             None => { |             None => { | ||||||
|                 // Album playlists fetched via the playlist method dont include a header
 |                 // Album playlists fetched via the playlist method dont include a header
 | ||||||
|                 let (album, cover) = map_res |                 let (album, cover) = map_res | ||||||
|                     .c |                     .c | ||||||
|                     .iter() |                     .first() | ||||||
|                     .find_map(|t: &TrackItem| { |                     .and_then(|t: &TrackItem| { | ||||||
|                         t.album.as_ref().map(|a| (a.clone(), t.cover.clone())) |                         t.album.as_ref().map(|a| (a.clone(), t.cover.clone())) | ||||||
|                     }) |                     }) | ||||||
|                     .ok_or(ExtractionError::InvalidData(Cow::Borrowed( |                     .ok_or(ExtractionError::InvalidData(Cow::Borrowed( | ||||||
|  | @ -311,8 +211,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist { | ||||||
|                     )))?; |                     )))?; | ||||||
| 
 | 
 | ||||||
|                 if !map_res.c.iter().all(|t| { |                 if !map_res.c.iter().all(|t| { | ||||||
|                     t.unavailable |                     t.album | ||||||
|                         || t.album |  | ||||||
|                         .as_ref() |                         .as_ref() | ||||||
|                         .map(|a| a.id == album.id) |                         .map(|a| a.id == album.id) | ||||||
|                         .unwrap_or_default() |                         .unwrap_or_default() | ||||||
|  | @ -328,28 +227,26 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist { | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: MusicPlaylist { |             c: MusicPlaylist { | ||||||
|                 id: ctx.id.to_owned(), |                 id: id.to_owned(), | ||||||
|                 name, |                 name, | ||||||
|                 thumbnail, |                 thumbnail, | ||||||
|                 channel, |                 channel, | ||||||
|                 description: description.map(RichText::from), |                 description, | ||||||
|                 track_count, |                 track_count, | ||||||
|                 from_ytm, |                 from_ytm, | ||||||
|                 tracks: Paginator::new_ext( |                 tracks: Paginator::new_ext( | ||||||
|                     track_count, |                     track_count, | ||||||
|                     map_res.c, |                     map_res.c, | ||||||
|                     ctoken, |                     ctoken, | ||||||
|                     ctx.visitor_data.map(str::to_owned), |                     None, | ||||||
|                     ContinuationEndpoint::MusicBrowse, |                     crate::model::paginator::ContinuationEndpoint::MusicBrowse, | ||||||
|                     ctx.authenticated, |  | ||||||
|                 ), |                 ), | ||||||
|                 related_playlists: Paginator::new_ext( |                 related_playlists: Paginator::new_ext( | ||||||
|                     None, |                     None, | ||||||
|                     Vec::new(), |                     Vec::new(), | ||||||
|                     related_ctoken, |                     related_ctoken, | ||||||
|                     ctx.visitor_data.map(str::to_owned), |                     None, | ||||||
|                     ContinuationEndpoint::MusicBrowse, |                     crate::model::paginator::ContinuationEndpoint::MusicBrowse, | ||||||
|                     ctx.authenticated, |  | ||||||
|                 ), |                 ), | ||||||
|             }, |             }, | ||||||
|             warnings: map_res.warnings, |             warnings: map_res.warnings, | ||||||
|  | @ -358,54 +255,27 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<MusicAlbum> for response::MusicPlaylist { | impl MapResponse<MusicAlbum> for response::MusicPlaylist { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<MusicAlbum>, ExtractionError> { |     fn map_response( | ||||||
|         let contents = match self.contents { |         self, | ||||||
|             Some(c) => c, |         id: &str, | ||||||
|             None => { |         lang: crate::param::Language, | ||||||
|                 if self.microformat.microformat_data_renderer.noindex { |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|                     return Err(ExtractionError::NotFound { |     ) -> Result<MapResult<MusicAlbum>, ExtractionError> { | ||||||
|                         id: ctx.id.to_owned(), |         // dbg!(&self);
 | ||||||
|                         msg: "no contents".into(), |  | ||||||
|                     }); |  | ||||||
|                 } else { |  | ||||||
|                     return Err(ExtractionError::InvalidData("no contents".into())); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 | 
 | ||||||
|         let (header, sections) = match contents { |         let header = self | ||||||
|             response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => ( |             .header | ||||||
|                 self.header, |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no header")))? | ||||||
|                 c.contents |             .music_detail_header_renderer; | ||||||
|                     .into_iter() | 
 | ||||||
|                     .next() |         let mut content = self.contents.single_column_browse_results_renderer.contents; | ||||||
|  |         let sections = content | ||||||
|  |             .try_swap_remove(0) | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? | ||||||
|             .tab_renderer |             .tab_renderer | ||||||
|             .content |             .content | ||||||
|             .section_list_renderer |             .section_list_renderer | ||||||
|                     .contents, |             .contents; | ||||||
|             ), |  | ||||||
|             response::music_playlist::Contents::TwoColumnBrowseResultsRenderer { |  | ||||||
|                 secondary_contents, |  | ||||||
|                 tabs, |  | ||||||
|             } => ( |  | ||||||
|                 tabs.into_iter() |  | ||||||
|                     .next() |  | ||||||
|                     .and_then(|t| { |  | ||||||
|                         t.tab_renderer |  | ||||||
|                             .content |  | ||||||
|                             .section_list_renderer |  | ||||||
|                             .contents |  | ||||||
|                             .into_iter() |  | ||||||
|                             .next() |  | ||||||
|                     }) |  | ||||||
|                     .or(self.header), |  | ||||||
|                 secondary_contents.section_list_renderer.contents, |  | ||||||
|             ), |  | ||||||
|         }; |  | ||||||
|         let header = header |  | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no header")))? |  | ||||||
|             .music_detail_header_renderer; |  | ||||||
| 
 | 
 | ||||||
|         let mut shelf = None; |         let mut shelf = None; | ||||||
|         let mut album_variants = None; |         let mut album_variants = None; | ||||||
|  | @ -413,18 +283,7 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist { | ||||||
|             match section { |             match section { | ||||||
|                 response::music_item::ItemSection::MusicShelfRenderer(sh) => shelf = Some(sh), |                 response::music_item::ItemSection::MusicShelfRenderer(sh) => shelf = Some(sh), | ||||||
|                 response::music_item::ItemSection::MusicCarouselShelfRenderer(sh) => { |                 response::music_item::ItemSection::MusicCarouselShelfRenderer(sh) => { | ||||||
|                     if sh |                     album_variants = Some(sh.contents) | ||||||
|                         .header |  | ||||||
|                         .map(|h| { |  | ||||||
|                             h.music_carousel_shelf_basic_header_renderer |  | ||||||
|                                 .title |  | ||||||
|                                 .first_str() |  | ||||||
|                                 == dictionary::entry(ctx.lang).album_versions_title |  | ||||||
|                         }) |  | ||||||
|                         .unwrap_or_default() |  | ||||||
|                     { |  | ||||||
|                         album_variants = Some(sh.contents); |  | ||||||
|                     } |  | ||||||
|                 } |                 } | ||||||
|                 _ => (), |                 _ => (), | ||||||
|             } |             } | ||||||
|  | @ -435,21 +294,12 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist { | ||||||
| 
 | 
 | ||||||
|         let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR); |         let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR); | ||||||
| 
 | 
 | ||||||
|         let (year_txt, artists_p) = match header.strapline_text_one { |         let (year_txt, artists_p) = match subtitle_split.len() { | ||||||
|             // New (2column) album layout
 |  | ||||||
|             Some(sl) => { |  | ||||||
|                 let year_txt = subtitle_split |  | ||||||
|                     .try_swap_remove(1) |  | ||||||
|                     .and_then(|t| t.0.first().map(|c| c.as_str().to_owned())); |  | ||||||
|                 (year_txt, Some(sl)) |  | ||||||
|             } |  | ||||||
|             // Old album layout
 |  | ||||||
|             None => match subtitle_split.len() { |  | ||||||
|             3.. => { |             3.. => { | ||||||
|                 let year_txt = subtitle_split |                 let year_txt = subtitle_split | ||||||
|                     .swap_remove(2) |                     .swap_remove(2) | ||||||
|                     .0 |                     .0 | ||||||
|                         .first() |                     .get(0) | ||||||
|                     .map(|c| c.as_str().to_owned()); |                     .map(|c| c.as_str().to_owned()); | ||||||
|                 (year_txt, subtitle_split.try_swap_remove(1)) |                 (year_txt, subtitle_split.try_swap_remove(1)) | ||||||
|             } |             } | ||||||
|  | @ -465,86 +315,50 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist { | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             _ => (None, None), |             _ => (None, None), | ||||||
|             }, |  | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let (artists, by_va) = map_artists(artists_p); |         let (artists, by_va) = map_artists(artists_p); | ||||||
|         let album_type_txt = subtitle_split |         let album_type_txt = subtitle_split | ||||||
|             .into_iter() |             .try_swap_remove(0) | ||||||
|             .next() |  | ||||||
|             .map(|part| part.to_string()) |             .map(|part| part.to_string()) | ||||||
|             .unwrap_or_default(); |             .unwrap_or_default(); | ||||||
| 
 | 
 | ||||||
|         let album_type = map_album_type(album_type_txt.as_str(), ctx.lang); |         let album_type = map_album_type(album_type_txt.as_str(), lang); | ||||||
|         let year = year_txt.and_then(|txt| util::parse_numeric(&txt).ok()); |         let year = year_txt.and_then(|txt| util::parse_numeric(&txt).ok()); | ||||||
| 
 | 
 | ||||||
|         fn map_playlist_id(ep: &NavigationEndpoint) -> Option<String> { |         let (artist_id, playlist_id) = header | ||||||
|             if let NavigationEndpoint::WatchPlaylist { |  | ||||||
|                 watch_playlist_endpoint, |  | ||||||
|             } = ep |  | ||||||
|             { |  | ||||||
|                 Some(watch_playlist_endpoint.playlist_id.to_owned()) |  | ||||||
|             } else { |  | ||||||
|                 None |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let playlist_id = self |  | ||||||
|             .microformat |  | ||||||
|             .microformat_data_renderer |  | ||||||
|             .url_canonical |  | ||||||
|             .and_then(|x| { |  | ||||||
|                 x.strip_prefix("https://music.youtube.com/playlist?list=") |  | ||||||
|                     .map(str::to_owned) |  | ||||||
|             }); |  | ||||||
|         let (playlist_id, artist_id) = header |  | ||||||
|             .menu |             .menu | ||||||
|             .or_else(|| header.buttons.into_iter().next()) |             .map(|mut menu| { | ||||||
|             .map(|menu| { |  | ||||||
|                 ( |                 ( | ||||||
|                     playlist_id.or_else(|| { |                     map_artist_id(menu.menu_renderer.items), | ||||||
|                     menu.menu_renderer |                     menu.menu_renderer | ||||||
|                         .top_level_buttons |                         .top_level_buttons | ||||||
|                             .iter() |                         .try_swap_remove(0) | ||||||
|                             .find_map(|btn| { |                         .map(|btn| { | ||||||
|                                 map_playlist_id(&btn.button_renderer.navigation_endpoint) |                             btn.button_renderer | ||||||
|                             }) |                                 .navigation_endpoint | ||||||
|                             .or_else(|| { |                                 .watch_playlist_endpoint | ||||||
|                                 menu.menu_renderer.items.iter().find_map(|itm| { |                                 .playlist_id | ||||||
|                                     map_playlist_id( |  | ||||||
|                                         &itm.menu_navigation_item_renderer.navigation_endpoint, |  | ||||||
|                                     ) |  | ||||||
|                                 }) |  | ||||||
|                             }) |  | ||||||
|                         }), |                         }), | ||||||
|                     map_artist_id(menu.menu_renderer.items), |  | ||||||
|                 ) |                 ) | ||||||
|             }) |             }) | ||||||
|             .unwrap_or_default(); |             .unwrap_or_default(); | ||||||
|         let artist_id = artist_id.or_else(|| artists.first().and_then(|a| a.id.clone())); |         let artist_id = artist_id.or_else(|| artists.first().and_then(|a| a.id.to_owned())); | ||||||
| 
 |  | ||||||
|         let second_subtitle_parts = header |  | ||||||
|             .second_subtitle |  | ||||||
|             .split(|p| p == DOT_SEPARATOR) |  | ||||||
|             .collect::<Vec<_>>(); |  | ||||||
|         let track_count = second_subtitle_parts |  | ||||||
|             .get(usize::from(second_subtitle_parts.len() > 2)) |  | ||||||
|             .and_then(|txt| util::parse_numeric::<u16>(&txt[0]).ok()); |  | ||||||
| 
 | 
 | ||||||
|         let mut mapper = MusicListMapper::with_album( |         let mut mapper = MusicListMapper::with_album( | ||||||
|             ctx.lang, |             lang, | ||||||
|             artists.clone(), |             artists.clone(), | ||||||
|             by_va, |             by_va, | ||||||
|             AlbumId { |             AlbumId { | ||||||
|                 id: ctx.id.to_owned(), |                 id: id.to_owned(), | ||||||
|                 name: header.title.clone(), |                 name: header.title.to_owned(), | ||||||
|             }, |             }, | ||||||
|         ); |         ); | ||||||
|         mapper.map_response(shelf.contents); |         mapper.map_response(shelf.contents); | ||||||
|         let tracks_res = mapper.conv_items(); |         let tracks_res = mapper.conv_items(); | ||||||
|         let mut warnings = tracks_res.warnings; |         let mut warnings = tracks_res.warnings; | ||||||
| 
 | 
 | ||||||
|         let mut variants_mapper = MusicListMapper::new(ctx.lang); |         let mut variants_mapper = MusicListMapper::new(lang); | ||||||
|         if let Some(res) = album_variants { |         if let Some(res) = album_variants { | ||||||
|             variants_mapper.map_response(res); |             variants_mapper.map_response(res); | ||||||
|         } |         } | ||||||
|  | @ -553,19 +367,16 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist { | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: MusicAlbum { |             c: MusicAlbum { | ||||||
|                 id: ctx.id.to_owned(), |                 id: id.to_owned(), | ||||||
|                 playlist_id, |                 playlist_id, | ||||||
|                 name: header.title, |                 name: header.title, | ||||||
|                 cover: header.thumbnail.into(), |                 cover: header.thumbnail.into(), | ||||||
|                 artists, |                 artists, | ||||||
|                 artist_id, |                 artist_id, | ||||||
|                 description: header |                 description: header.description, | ||||||
|                     .description |  | ||||||
|                     .map(|t| RichText::from(TextComponents::from(t))), |  | ||||||
|                 album_type, |                 album_type, | ||||||
|                 year, |                 year, | ||||||
|                 by_va, |                 by_va, | ||||||
|                 track_count: track_count.unwrap_or(tracks_res.c.len() as u16), |  | ||||||
|                 tracks: tracks_res.c, |                 tracks: tracks_res.c, | ||||||
|                 variants: variants_res.c, |                 variants: variants_res.c, | ||||||
|             }, |             }, | ||||||
|  | @ -582,15 +393,12 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
|     use crate::{model, util::tests::TESTFILES}; |     use crate::{model, param::Language, util::tests::TESTFILES}; | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] |     #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] | ||||||
|     #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] |     #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] | ||||||
|     #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] |     #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] | ||||||
|     #[case::two_columns("20240228_twoColumns", "RDCLAK5uy_kb7EBi6y3GrtJri4_ZH56Ms786DFEimbM")] |  | ||||||
|     #[case::n_album("20240228_album", "OLAK5uy_kdSWBZ-9AZDkYkuy0QCc3p0KO9DEHVNH0")] |  | ||||||
|     #[case::facepile("20241125_facepile", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] |  | ||||||
|     fn map_music_playlist(#[case] name: &str, #[case] id: &str) { |     fn map_music_playlist(#[case] name: &str, #[case] id: &str) { | ||||||
|         let json_path = path!(*TESTFILES / "music_playlist" / format!("playlist_{name}.json")); |         let json_path = path!(*TESTFILES / "music_playlist" / format!("playlist_{name}.json")); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | @ -598,7 +406,7 @@ mod tests { | ||||||
|         let playlist: response::MusicPlaylist = |         let playlist: response::MusicPlaylist = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<model::MusicPlaylist> = |         let map_res: MapResult<model::MusicPlaylist> = | ||||||
|             playlist.map_response(&MapRespCtx::test(id)).unwrap(); |             playlist.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -616,8 +424,6 @@ mod tests { | ||||||
|     #[case::single("single", "MPREb_bHfHGoy7vuv")] |     #[case::single("single", "MPREb_bHfHGoy7vuv")] | ||||||
|     #[case::description("description", "MPREb_PiyfuVl6aYd")] |     #[case::description("description", "MPREb_PiyfuVl6aYd")] | ||||||
|     #[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")] |     #[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")] | ||||||
|     #[case::two_columns("20240228_twoColumns", "MPREb_bHfHGoy7vuv")] |  | ||||||
|     #[case::recommends("20250225_recommends", "MPREb_u1I69lSAe5v")] |  | ||||||
|     fn map_music_album(#[case] name: &str, #[case] id: &str) { |     fn map_music_album(#[case] name: &str, #[case] id: &str) { | ||||||
|         let json_path = path!(*TESTFILES / "music_playlist" / format!("album_{name}.json")); |         let json_path = path!(*TESTFILES / "music_playlist" / format!("album_{name}.json")); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | @ -625,7 +431,7 @@ mod tests { | ||||||
|         let playlist: response::MusicPlaylist = |         let playlist: response::MusicPlaylist = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<model::MusicAlbum> = |         let map_res: MapResult<model::MusicAlbum> = | ||||||
|             playlist.map_response(&MapRespCtx::test(id)).unwrap(); |             playlist.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| use std::{borrow::Cow, fmt::Debug}; | use std::borrow::Cow; | ||||||
| 
 | 
 | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| 
 | 
 | ||||||
|  | @ -6,45 +6,97 @@ use crate::{ | ||||||
|     client::response::music_item::MusicListMapper, |     client::response::music_item::MusicListMapper, | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{ |     model::{ | ||||||
|         paginator::{ContinuationEndpoint, Paginator}, |         paginator::Paginator, traits::FromYtItem, AlbumItem, ArtistItem, MusicPlaylistItem, | ||||||
|         traits::FromYtItem, |         MusicSearchFiltered, MusicSearchResult, MusicSearchSuggestion, TrackItem, | ||||||
|         AlbumItem, ArtistItem, MusicItem, MusicPlaylistItem, MusicSearchResult, |  | ||||||
|         MusicSearchSuggestion, TrackItem, UserItem, |  | ||||||
|     }, |     }, | ||||||
|     param::search_filter::MusicSearchFilter, |  | ||||||
|     serializer::MapResult, |     serializer::MapResult, | ||||||
|  |     util::TryRemove, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{response, ClientType, MapRespCtx, MapResponse, RustyPipeQuery}; | use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext}; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct QSearch<'a> { | struct QSearch<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     query: &'a str, |     query: &'a str, | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     params: Option<&'a str>, |     params: Option<Params>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct QSearchSuggestion<'a> { | struct QSearchSuggestion<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     input: &'a str, |     input: &'a str, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | enum Params { | ||||||
|  |     #[serde(rename = "EgWKAQIIAWoMEAMQBBAJEA4QChAF")] | ||||||
|  |     Tracks, | ||||||
|  |     #[serde(rename = "EgWKAQIQAWoMEAMQBBAJEA4QChAF")] | ||||||
|  |     Videos, | ||||||
|  |     #[serde(rename = "EgWKAQIYAWoMEAMQBBAJEA4QChAF")] | ||||||
|  |     Albums, | ||||||
|  |     #[serde(rename = "EgWKAQIgAWoMEAMQBBAJEA4QChAF")] | ||||||
|  |     Artists, | ||||||
|  |     #[serde(rename = "EgWKAQIoAWoMEAMQBBAJEA4QChAF")] | ||||||
|  |     Playlists, | ||||||
|  |     #[serde(rename = "EgeKAQQoADgBagwQAxAEEAkQDhAKEAU%3D")] | ||||||
|  |     YtmPlaylists, | ||||||
|  |     #[serde(rename = "EgeKAQQoAEABagwQAxAEEAkQDhAKEAU%3D")] | ||||||
|  |     CommunityPlaylists, | ||||||
|  | } | ||||||
|  | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Search YouTube Music.
 |     /// Search YouTube Music. Returns items from any type.
 | ||||||
|     ///
 |     pub async fn music_search<S: AsRef<str>>(&self, query: S) -> Result<MusicSearchResult, Error> { | ||||||
|     /// This is a generic implementation which casts items to the given type or filters
 |         let query = query.as_ref(); | ||||||
|     /// them out.
 |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|     pub async fn music_search<T: FromYtItem, S: AsRef<str>>( |         let request_body = QSearch { | ||||||
|  |             context, | ||||||
|  |             query, | ||||||
|  |             params: None, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         self.execute_request::<response::MusicSearch, _, _>( | ||||||
|  |             ClientType::DesktopMusic, | ||||||
|  |             "music_search", | ||||||
|  |             query, | ||||||
|  |             "search", | ||||||
|  |             &request_body, | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Search YouTube Music tracks
 | ||||||
|  |     pub async fn music_search_tracks<S: AsRef<str>>( | ||||||
|         &self, |         &self, | ||||||
|         query: S, |         query: S, | ||||||
|         filter: Option<MusicSearchFilter>, |     ) -> Result<MusicSearchFiltered<TrackItem>, Error> { | ||||||
|     ) -> Result<MusicSearchResult<T>, Error> { |         self._music_search_tracks(query, Params::Tracks).await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Search YouTube Music videos
 | ||||||
|  |     pub async fn music_search_videos<S: AsRef<str>>( | ||||||
|  |         &self, | ||||||
|  |         query: S, | ||||||
|  |     ) -> Result<MusicSearchFiltered<TrackItem>, Error> { | ||||||
|  |         self._music_search_tracks(query, Params::Videos).await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async fn _music_search_tracks<S: AsRef<str>>( | ||||||
|  |         &self, | ||||||
|  |         query: S, | ||||||
|  |         params: Params, | ||||||
|  |     ) -> Result<MusicSearchFiltered<TrackItem>, Error> { | ||||||
|         let query = query.as_ref(); |         let query = query.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|         let request_body = QSearch { |         let request_body = QSearch { | ||||||
|  |             context, | ||||||
|             query, |             query, | ||||||
|             params: filter.map(MusicSearchFilter::params), |             params: Some(params), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         self.execute_request::<response::MusicSearch, _, _>( |         self.execute_request::<response::MusicSearch, _, _>( | ||||||
|  | @ -57,87 +109,110 @@ impl RustyPipeQuery { | ||||||
|         .await |         .await | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Search YouTube Music and return items of all types
 |  | ||||||
|     pub async fn music_search_main<S: AsRef<str>>( |  | ||||||
|         &self, |  | ||||||
|         query: S, |  | ||||||
|     ) -> Result<MusicSearchResult<MusicItem>, Error> { |  | ||||||
|         self.music_search(query, None).await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Search YouTube Music artists
 |  | ||||||
|     pub async fn music_search_artists<S: AsRef<str>>( |  | ||||||
|         &self, |  | ||||||
|         query: S, |  | ||||||
|     ) -> Result<MusicSearchResult<ArtistItem>, Error> { |  | ||||||
|         self.music_search(query, Some(MusicSearchFilter::Artists)) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Search YouTube Music albums
 |     /// Search YouTube Music albums
 | ||||||
|     pub async fn music_search_albums<S: AsRef<str>>( |     pub async fn music_search_albums<S: AsRef<str>>( | ||||||
|         &self, |         &self, | ||||||
|         query: S, |         query: S, | ||||||
|     ) -> Result<MusicSearchResult<AlbumItem>, Error> { |     ) -> Result<MusicSearchFiltered<AlbumItem>, Error> { | ||||||
|         self.music_search(query, Some(MusicSearchFilter::Albums)) |         let query = query.as_ref(); | ||||||
|             .await |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|     } |         let request_body = QSearch { | ||||||
| 
 |             context, | ||||||
|     /// Search YouTube Music tracks
 |  | ||||||
|     pub async fn music_search_tracks<S: AsRef<str>>( |  | ||||||
|         &self, |  | ||||||
|         query: S, |  | ||||||
|     ) -> Result<MusicSearchResult<TrackItem>, Error> { |  | ||||||
|         self.music_search(query, Some(MusicSearchFilter::Tracks)) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Search YouTube Music videos
 |  | ||||||
|     pub async fn music_search_videos<S: AsRef<str>>( |  | ||||||
|         &self, |  | ||||||
|         query: S, |  | ||||||
|     ) -> Result<MusicSearchResult<TrackItem>, Error> { |  | ||||||
|         self.music_search(query, Some(MusicSearchFilter::Videos)) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Search YouTube Music playlists
 |  | ||||||
|     ///
 |  | ||||||
|     /// Playlists are filtered whether they are created by users
 |  | ||||||
|     /// (`community=true`) or by YouTube Music (`community=false`)
 |  | ||||||
|     pub async fn music_search_playlists<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |  | ||||||
|         query: S, |  | ||||||
|         community: bool, |  | ||||||
|     ) -> Result<MusicSearchResult<MusicPlaylistItem>, Error> { |  | ||||||
|         self.music_search( |  | ||||||
|             query, |             query, | ||||||
|             Some(if community { |             params: Some(Params::Albums), | ||||||
|                 MusicSearchFilter::CommunityPlaylists |         }; | ||||||
|             } else { | 
 | ||||||
|                 MusicSearchFilter::YtmPlaylists |         self.execute_request::<response::MusicSearch, _, _>( | ||||||
|             }), |             ClientType::DesktopMusic, | ||||||
|  |             "music_search_albums", | ||||||
|  |             query, | ||||||
|  |             "search", | ||||||
|  |             &request_body, | ||||||
|         ) |         ) | ||||||
|         .await |         .await | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Search YouTube Music users
 |     /// Search YouTube Music artists
 | ||||||
|     pub async fn music_search_users<S: AsRef<str>>( |     pub async fn music_search_artists( | ||||||
|  |         &self, | ||||||
|  |         query: &str, | ||||||
|  |     ) -> Result<MusicSearchFiltered<ArtistItem>, Error> { | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|  |         let request_body = QSearch { | ||||||
|  |             context, | ||||||
|  |             query, | ||||||
|  |             params: Some(Params::Artists), | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         self.execute_request::<response::MusicSearch, _, _>( | ||||||
|  |             ClientType::DesktopMusic, | ||||||
|  |             "music_search_albums", | ||||||
|  |             query, | ||||||
|  |             "search", | ||||||
|  |             &request_body, | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Search YouTube Music playlists
 | ||||||
|  |     pub async fn music_search_playlists<S: AsRef<str>>( | ||||||
|         &self, |         &self, | ||||||
|         query: S, |         query: S, | ||||||
|     ) -> Result<MusicSearchResult<UserItem>, Error> { |     ) -> Result<MusicSearchFiltered<MusicPlaylistItem>, Error> { | ||||||
|         self.music_search(query, Some(MusicSearchFilter::Users)) |         self._music_search_playlists(query, Params::Playlists).await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Search YouTube Music playlists that were created by users
 | ||||||
|  |     /// (`community=true`) or by YouTube Music (`community=false`)
 | ||||||
|  |     pub async fn music_search_playlists_filter<S: AsRef<str>>( | ||||||
|  |         &self, | ||||||
|  |         query: S, | ||||||
|  |         community: bool, | ||||||
|  |     ) -> Result<MusicSearchFiltered<MusicPlaylistItem>, Error> { | ||||||
|  |         self._music_search_playlists( | ||||||
|  |             query, | ||||||
|  |             match community { | ||||||
|  |                 true => Params::CommunityPlaylists, | ||||||
|  |                 false => Params::YtmPlaylists, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async fn _music_search_playlists<S: AsRef<str>>( | ||||||
|  |         &self, | ||||||
|  |         query: S, | ||||||
|  |         params: Params, | ||||||
|  |     ) -> Result<MusicSearchFiltered<MusicPlaylistItem>, Error> { | ||||||
|  |         let query = query.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|  |         let request_body = QSearch { | ||||||
|  |             context, | ||||||
|  |             query, | ||||||
|  |             params: Some(params), | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         self.execute_request::<response::MusicSearch, _, _>( | ||||||
|  |             ClientType::DesktopMusic, | ||||||
|  |             "music_search_playlists", | ||||||
|  |             query, | ||||||
|  |             "search", | ||||||
|  |             &request_body, | ||||||
|  |         ) | ||||||
|         .await |         .await | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get YouTube Music search suggestions
 |     /// Get YouTube Music search suggestions
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn music_search_suggestion<S: AsRef<str>>( | ||||||
|     pub async fn music_search_suggestion<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         query: S, |         query: S, | ||||||
|     ) -> Result<MusicSearchSuggestion, Error> { |     ) -> Result<MusicSearchSuggestion, Error> { | ||||||
|         let query = query.as_ref(); |         let query = query.as_ref(); | ||||||
|         let request_body = QSearchSuggestion { input: query }; |         let context = self.get_context(ClientType::DesktopMusic, true, None).await; | ||||||
|  |         let request_body = QSearchSuggestion { | ||||||
|  |             context, | ||||||
|  |             input: query, | ||||||
|  |         }; | ||||||
| 
 | 
 | ||||||
|         self.execute_request::<response::MusicSearchSuggestion, _, _>( |         self.execute_request::<response::MusicSearchSuggestion, _, _>( | ||||||
|             ClientType::DesktopMusic, |             ClientType::DesktopMusic, | ||||||
|  | @ -150,15 +225,79 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch { | impl MapResponse<MusicSearchResult> for response::MusicSearch { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|     ) -> Result<MapResult<MusicSearchResult<T>>, ExtractionError> { |         lang: crate::param::Language, | ||||||
|         let tabs = self.contents.tabbed_search_results_renderer.contents; |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<MusicSearchResult>, crate::error::ExtractionError> { | ||||||
|  |         // dbg!(&self);
 | ||||||
|  | 
 | ||||||
|  |         let mut tabs = self.contents.tabbed_search_results_renderer.contents; | ||||||
|         let sections = tabs |         let sections = tabs | ||||||
|             .into_iter() |             .try_swap_remove(0) | ||||||
|             .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 { mut contents } => { | ||||||
|  |                 if let Some(corrected) = contents.try_swap_remove(0) { | ||||||
|  |                     corrected_query = Some(corrected.showing_results_for_renderer.corrected_query) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             response::music_search::ItemSection::None => {} | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         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 mut tabs = self.contents.tabbed_search_results_renderer.contents; | ||||||
|  |         let sections = tabs | ||||||
|  |             .try_swap_remove(0) | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no tab")))? |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no tab")))? | ||||||
|             .tab_renderer |             .tab_renderer | ||||||
|             .content |             .content | ||||||
|  | @ -167,38 +306,36 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch | ||||||
| 
 | 
 | ||||||
|         let mut corrected_query = None; |         let mut corrected_query = None; | ||||||
|         let mut ctoken = None; |         let mut ctoken = None; | ||||||
|         let mut mapper = MusicListMapper::new(ctx.lang); |         let mut mapper = MusicListMapper::new(lang); | ||||||
| 
 | 
 | ||||||
|         sections.into_iter().for_each(|section| match section { |         sections.into_iter().for_each(|section| match section { | ||||||
|             response::music_search::ItemSection::MusicShelfRenderer(shelf) => { |             response::music_search::ItemSection::MusicShelfRenderer(mut shelf) => { | ||||||
|                 mapper.map_response(shelf.contents); |                 mapper.map_response(shelf.contents); | ||||||
|                 if let Some(cont) = shelf.continuations.into_iter().next() { |                 if let Some(cont) = shelf.continuations.try_swap_remove(0) { | ||||||
|                     ctoken = Some(cont.next_continuation_data.continuation); |                     ctoken = Some(cont.next_continuation_data.continuation); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             response::music_search::ItemSection::MusicCardShelfRenderer(card) => { |             response::music_search::ItemSection::MusicCardShelfRenderer(card) => { | ||||||
|                 mapper.map_card(card); |                 mapper.map_card(card); | ||||||
|             } |             } | ||||||
|             response::music_search::ItemSection::ItemSectionRenderer { contents } => { |             response::music_search::ItemSection::ItemSectionRenderer { mut contents } => { | ||||||
|                 if let Some(corrected) = contents.into_iter().next() { |                 if let Some(corrected) = contents.try_swap_remove(0) { | ||||||
|                     corrected_query = Some(corrected.showing_results_for_renderer.corrected_query); |                     corrected_query = Some(corrected.showing_results_for_renderer.corrected_query) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             response::music_search::ItemSection::None => {} |             response::music_search::ItemSection::None => {} | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         let ctoken = ctoken.or(mapper.ctoken.clone()); |  | ||||||
|         let map_res = mapper.conv_items(); |         let map_res = mapper.conv_items(); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: MusicSearchResult { |             c: MusicSearchFiltered { | ||||||
|                 items: Paginator::new_ext( |                 items: Paginator::new_ext( | ||||||
|                     None, |                     None, | ||||||
|                     map_res.c, |                     map_res.c, | ||||||
|                     ctoken, |                     ctoken, | ||||||
|                     ctx.visitor_data.map(str::to_owned), |                     None, | ||||||
|                     ContinuationEndpoint::MusicSearch, |                     crate::model::paginator::ContinuationEndpoint::MusicSearch, | ||||||
|                     false, |  | ||||||
|                 ), |                 ), | ||||||
|                 corrected_query, |                 corrected_query, | ||||||
|             }, |             }, | ||||||
|  | @ -210,9 +347,11 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch | ||||||
| impl MapResponse<MusicSearchSuggestion> for response::MusicSearchSuggestion { | impl MapResponse<MusicSearchSuggestion> for response::MusicSearchSuggestion { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<MusicSearchSuggestion>, ExtractionError> { |     ) -> Result<MapResult<MusicSearchSuggestion>, ExtractionError> { | ||||||
|         let mut mapper = MusicListMapper::new_search_suggest(ctx.lang); |         let mut mapper = MusicListMapper::new(lang); | ||||||
|         let mut terms = Vec::new(); |         let mut terms = Vec::new(); | ||||||
| 
 | 
 | ||||||
|         for section in self.contents { |         for section in self.contents { | ||||||
|  | @ -251,11 +390,12 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use crate::{ |     use crate::{ | ||||||
|         client::{response, MapRespCtx, MapResponse}, |         client::{response, MapResponse}, | ||||||
|         model::{ |         model::{ | ||||||
|             AlbumItem, ArtistItem, MusicItem, MusicPlaylistItem, MusicSearchResult, |             AlbumItem, ArtistItem, MusicPlaylistItem, MusicSearchFiltered, MusicSearchResult, | ||||||
|             MusicSearchSuggestion, TrackItem, |             MusicSearchSuggestion, TrackItem, | ||||||
|         }, |         }, | ||||||
|  |         param::Language, | ||||||
|         serializer::MapResult, |         serializer::MapResult, | ||||||
|         util::tests::TESTFILES, |         util::tests::TESTFILES, | ||||||
|     }; |     }; | ||||||
|  | @ -264,16 +404,15 @@ mod tests { | ||||||
|     #[case::default("default")] |     #[case::default("default")] | ||||||
|     #[case::typo("typo")] |     #[case::typo("typo")] | ||||||
|     #[case::radio("radio")] |     #[case::radio("radio")] | ||||||
|     #[case::artist("artist")] |     #[case::radio("artist")] | ||||||
|     #[case::live("live")] |  | ||||||
|     fn map_music_search_main(#[case] name: &str) { |     fn map_music_search_main(#[case] name: &str) { | ||||||
|         let json_path = path!(*TESTFILES / "music_search" / format!("main_{name}.json")); |         let json_path = path!(*TESTFILES / "music_search" / format!("main_{name}.json")); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let search: response::MusicSearch = |         let search: response::MusicSearch = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicSearchResult<MusicItem>> = |         let map_res: MapResult<MusicSearchResult> = | ||||||
|             search.map_response(&MapRespCtx::test("")).unwrap(); |             search.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -295,8 +434,8 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|         let search: response::MusicSearch = |         let search: response::MusicSearch = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicSearchResult<TrackItem>> = |         let map_res: MapResult<MusicSearchFiltered<TrackItem>> = | ||||||
|             search.map_response(&MapRespCtx::test("")).unwrap(); |             search.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -314,8 +453,8 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|         let search: response::MusicSearch = |         let search: response::MusicSearch = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicSearchResult<AlbumItem>> = |         let map_res: MapResult<MusicSearchFiltered<AlbumItem>> = | ||||||
|             search.map_response(&MapRespCtx::test("")).unwrap(); |             search.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -333,8 +472,8 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|         let search: response::MusicSearch = |         let search: response::MusicSearch = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicSearchResult<ArtistItem>> = |         let map_res: MapResult<MusicSearchFiltered<ArtistItem>> = | ||||||
|             search.map_response(&MapRespCtx::test("")).unwrap(); |             search.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -354,8 +493,8 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|         let search: response::MusicSearch = |         let search: response::MusicSearch = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicSearchResult<MusicPlaylistItem>> = |         let map_res: MapResult<MusicSearchFiltered<MusicPlaylistItem>> = | ||||||
|             search.map_response(&MapRespCtx::test("")).unwrap(); |             search.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -376,7 +515,7 @@ mod tests { | ||||||
|         let suggestion: response::MusicSearchSuggestion = |         let suggestion: response::MusicSearchSuggestion = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<MusicSearchSuggestion> = |         let map_res: MapResult<MusicSearchSuggestion> = | ||||||
|             suggestion.map_response(&MapRespCtx::test("")).unwrap(); |             suggestion.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
|  | @ -1,228 +0,0 @@ | ||||||
| use std::fmt::Debug; |  | ||||||
| 
 |  | ||||||
| use crate::{ |  | ||||||
|     client::{ |  | ||||||
|         response::{self, music_item::MusicListMapper}, |  | ||||||
|         ClientType, MapResponse, QBrowseParams, RustyPipeQuery, |  | ||||||
|     }, |  | ||||||
|     error::{Error, ExtractionError}, |  | ||||||
|     model::{ |  | ||||||
|         paginator::{ContinuationEndpoint, Paginator}, |  | ||||||
|         AlbumItem, ArtistItem, HistoryItem, MusicPlaylist, MusicPlaylistItem, TrackItem, |  | ||||||
|     }, |  | ||||||
|     serializer::MapResult, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| use super::{MapRespCtx, MapRespOptions, QContinuation}; |  | ||||||
| 
 |  | ||||||
| impl RustyPipeQuery { |  | ||||||
|     /// Get a list of tracks from YouTube Music which the current user recently played
 |  | ||||||
|     ///
 |  | ||||||
|     /// Requires authentication cookies.
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_history(&self) -> Result<Paginator<HistoryItem<TrackItem>>, Error> { |  | ||||||
|         let request_body = QBrowseParams { |  | ||||||
|             browse_id: "FEmusic_history", |  | ||||||
|             params: "oggECgIIAQ%3D%3D", |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         self.clone() |  | ||||||
|             .authenticated() |  | ||||||
|             .execute_request::<response::MusicHistory, _, _>( |  | ||||||
|                 ClientType::DesktopMusic, |  | ||||||
|                 "music_history", |  | ||||||
|                 "", |  | ||||||
|                 "browse", |  | ||||||
|                 &request_body, |  | ||||||
|             ) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Get more YouTube Music history items from the given continuation token
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_history_continuation<S: AsRef<str> + Debug>( |  | ||||||
|         &self, |  | ||||||
|         ctoken: S, |  | ||||||
|         visitor_data: Option<&str>, |  | ||||||
|     ) -> Result<Paginator<HistoryItem<TrackItem>>, Error> { |  | ||||||
|         let ctoken = ctoken.as_ref(); |  | ||||||
|         let request_body = QContinuation { |  | ||||||
|             continuation: ctoken, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         self.clone() |  | ||||||
|             .authenticated() |  | ||||||
|             .execute_request_ctx::<response::MusicContinuation, _, _>( |  | ||||||
|                 ClientType::Desktop, |  | ||||||
|                 "history_continuation", |  | ||||||
|                 ctoken, |  | ||||||
|                 "browse", |  | ||||||
|                 &request_body, |  | ||||||
|                 MapRespOptions { |  | ||||||
|                     visitor_data, |  | ||||||
|                     ..Default::default() |  | ||||||
|                 }, |  | ||||||
|             ) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Get a list of YouTube Music artists which the current user subscribed to
 |  | ||||||
|     ///
 |  | ||||||
|     /// Requires authentication cookies.
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_saved_artists(&self) -> Result<Paginator<ArtistItem>, Error> { |  | ||||||
|         self.clone() |  | ||||||
|             .authenticated() |  | ||||||
|             .continuation( |  | ||||||
|                 "4qmFsgIyEh5GRW11c2ljX2xpYnJhcnlfY29ycHVzX2FydGlzdHMaEGdnTUdLZ1FJQUJBQm9BWUI%3D", |  | ||||||
|                 ContinuationEndpoint::MusicBrowse, |  | ||||||
|                 None, |  | ||||||
|             ) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Get a list of YouTube Music albums which the current user has added to their collection
 |  | ||||||
|     ///
 |  | ||||||
|     /// Requires authentication cookies.
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_saved_albums(&self) -> Result<Paginator<AlbumItem>, Error> { |  | ||||||
|         self.clone() |  | ||||||
|             .authenticated() |  | ||||||
|             .continuation( |  | ||||||
|                 "4qmFsgIoEhRGRW11c2ljX2xpa2VkX2FsYnVtcxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D", |  | ||||||
|                 ContinuationEndpoint::MusicBrowse, |  | ||||||
|                 None, |  | ||||||
|             ) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Get a list of YouTube Music tracks which the current user has added to their collection
 |  | ||||||
|     ///
 |  | ||||||
|     /// Contains both liked tracks and tracks from saved albums.
 |  | ||||||
|     ///
 |  | ||||||
|     /// Requires authentication cookies.
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_saved_tracks(&self) -> Result<Paginator<TrackItem>, Error> { |  | ||||||
|         self.clone() |  | ||||||
|             .authenticated() |  | ||||||
|             .continuation( |  | ||||||
|                 "4qmFsgIoEhRGRW11c2ljX2xpa2VkX3ZpZGVvcxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D", |  | ||||||
|                 ContinuationEndpoint::MusicBrowse, |  | ||||||
|                 None, |  | ||||||
|             ) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Get a list of YouTube Music playlists which the current user has added to their collection
 |  | ||||||
|     ///
 |  | ||||||
|     /// Requires authentication cookies.
 |  | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |  | ||||||
|     pub async fn music_saved_playlists(&self) -> Result<Paginator<MusicPlaylistItem>, Error> { |  | ||||||
|         self.clone() |  | ||||||
|             .authenticated() |  | ||||||
|             .continuation( |  | ||||||
|                 "4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D", |  | ||||||
|                 ContinuationEndpoint::MusicBrowse, |  | ||||||
|                 None, |  | ||||||
|             ) |  | ||||||
|             .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Get all liked YouTube Music tracks of the logged-in user
 |  | ||||||
|     ///
 |  | ||||||
|     /// The difference to [`RustyPipeQuery::music_saved_tracks`] is that this function only returns
 |  | ||||||
|     /// tracks that were explicitly liked by the user.
 |  | ||||||
|     ///
 |  | ||||||
|     /// Requires authentication cookies.
 |  | ||||||
|     pub async fn music_liked_tracks(&self) -> Result<MusicPlaylist, Error> { |  | ||||||
|         self.clone() |  | ||||||
|             .authenticated() |  | ||||||
|             .music_playlist("LM") |  | ||||||
|             .await |  | ||||||
|             .map_err(crate::util::map_internal_playlist_err) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicHistory { |  | ||||||
|     fn map_response( |  | ||||||
|         self, |  | ||||||
|         ctx: &MapRespCtx<'_>, |  | ||||||
|     ) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> { |  | ||||||
|         let contents = match self.contents { |  | ||||||
|             response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => { |  | ||||||
|                 c.contents |  | ||||||
|                     .into_iter() |  | ||||||
|                     .next() |  | ||||||
|                     .ok_or(ExtractionError::InvalidData("no content".into()))? |  | ||||||
|                     .tab_renderer |  | ||||||
|                     .content |  | ||||||
|                     .section_list_renderer |  | ||||||
|             } |  | ||||||
|             response::music_playlist::Contents::TwoColumnBrowseResultsRenderer { |  | ||||||
|                 secondary_contents, |  | ||||||
|                 .. |  | ||||||
|             } => secondary_contents.section_list_renderer, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let mut map_res = MapResult::default(); |  | ||||||
| 
 |  | ||||||
|         for shelf in contents.contents { |  | ||||||
|             let shelf = if let response::music_item::ItemSection::MusicShelfRenderer(s) = shelf { |  | ||||||
|                 s |  | ||||||
|             } else { |  | ||||||
|                 continue; |  | ||||||
|             }; |  | ||||||
|             let mut mapper = MusicListMapper::new(ctx.lang); |  | ||||||
|             mapper.map_response(shelf.contents); |  | ||||||
|             mapper.conv_history_items(shelf.title, ctx.utc_offset, &mut map_res); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let ctoken = contents |  | ||||||
|             .continuations |  | ||||||
|             .into_iter() |  | ||||||
|             .next() |  | ||||||
|             .map(|c| c.next_continuation_data.continuation); |  | ||||||
| 
 |  | ||||||
|         Ok(MapResult { |  | ||||||
|             c: Paginator::new_ext( |  | ||||||
|                 None, |  | ||||||
|                 map_res.c, |  | ||||||
|                 ctoken, |  | ||||||
|                 ctx.visitor_data.map(str::to_owned), |  | ||||||
|                 ContinuationEndpoint::MusicBrowse, |  | ||||||
|                 true, |  | ||||||
|             ), |  | ||||||
|             warnings: map_res.warnings, |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
|     use std::{fs::File, io::BufReader}; |  | ||||||
| 
 |  | ||||||
|     use path_macro::path; |  | ||||||
| 
 |  | ||||||
|     use crate::util::tests::TESTFILES; |  | ||||||
| 
 |  | ||||||
|     use super::*; |  | ||||||
| 
 |  | ||||||
|     #[test] |  | ||||||
|     fn map_history() { |  | ||||||
|         let json_path = path!(*TESTFILES / "music_userdata" / "music_history.json"); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
| 
 |  | ||||||
|         let history: response::MusicHistory = |  | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let map_res = history.map_response(&MapRespCtx::test("")).unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert!( |  | ||||||
|             map_res.warnings.is_empty(), |  | ||||||
|             "deserialization/mapping warnings: {:?}", |  | ||||||
|             map_res.warnings |  | ||||||
|         ); |  | ||||||
|         insta::assert_ron_snapshot!(map_res.c, { |  | ||||||
|             ".items[].playback_date" => "[date]", |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,28 +1,18 @@ | ||||||
| use std::fmt::Debug; |  | ||||||
| 
 |  | ||||||
| use crate::error::{Error, ExtractionError}; | use crate::error::{Error, ExtractionError}; | ||||||
| use crate::model::{ | use crate::model::{ | ||||||
|     paginator::{ContinuationEndpoint, Paginator}, |     paginator::{ContinuationEndpoint, Paginator}, | ||||||
|     traits::FromYtItem, |     traits::FromYtItem, | ||||||
|     Comment, MusicItem, YouTubeItem, |     Comment, MusicItem, PlaylistVideo, YouTubeItem, | ||||||
| }; | }; | ||||||
| use crate::serializer::MapResult; | use crate::serializer::MapResult; | ||||||
|  | use crate::util::TryRemove; | ||||||
| 
 | 
 | ||||||
| #[cfg(feature = "userdata")] | use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo}; | ||||||
| use crate::model::{HistoryItem, TrackItem, VideoItem}; | use super::{response, ClientType, MapResponse, QContinuation, RustyPipeQuery}; | ||||||
| 
 |  | ||||||
| use super::response::{ |  | ||||||
|     music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo}, |  | ||||||
|     YouTubeListItem, |  | ||||||
| }; |  | ||||||
| use super::{ |  | ||||||
|     response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Get more YouTube items from the given continuation token and endpoint
 |     /// Get more YouTube items from the given continuation token and endpoint
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn continuation<T: FromYtItem, S: AsRef<str>>( | ||||||
|     pub async fn continuation<T: FromYtItem, S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         ctoken: S, |         ctoken: S, | ||||||
|         endpoint: ContinuationEndpoint, |         endpoint: ContinuationEndpoint, | ||||||
|  | @ -30,118 +20,102 @@ impl RustyPipeQuery { | ||||||
|     ) -> Result<Paginator<T>, Error> { |     ) -> Result<Paginator<T>, Error> { | ||||||
|         let ctoken = ctoken.as_ref(); |         let ctoken = ctoken.as_ref(); | ||||||
|         if endpoint.is_music() { |         if endpoint.is_music() { | ||||||
|  |             let context = self | ||||||
|  |                 .get_context(ClientType::DesktopMusic, true, visitor_data) | ||||||
|  |                 .await; | ||||||
|             let request_body = QContinuation { |             let request_body = QContinuation { | ||||||
|  |                 context, | ||||||
|                 continuation: ctoken, |                 continuation: ctoken, | ||||||
|             }; |             }; | ||||||
| 
 | 
 | ||||||
|             let p = self |             let p = self | ||||||
|                 .execute_request_ctx::<response::MusicContinuation, Paginator<MusicItem>, _>( |                 .execute_request::<response::MusicContinuation, Paginator<MusicItem>, _>( | ||||||
|                     ClientType::DesktopMusic, |                     ClientType::DesktopMusic, | ||||||
|                     "music_continuation", |                     "music_continuation", | ||||||
|                     ctoken, |                     ctoken, | ||||||
|                     endpoint.as_str(), |                     endpoint.as_str(), | ||||||
|                     &request_body, |                     &request_body, | ||||||
|                     MapRespOptions { |  | ||||||
|                         visitor_data, |  | ||||||
|                         ..Default::default() |  | ||||||
|                     }, |  | ||||||
|                 ) |                 ) | ||||||
|                 .await?; |                 .await?; | ||||||
| 
 | 
 | ||||||
|             Ok(map_ytm_paginator(p, endpoint)) |             Ok(map_ytm_paginator(p, visitor_data, endpoint)) | ||||||
|         } else { |         } else { | ||||||
|  |             let context = self | ||||||
|  |                 .get_context(ClientType::Desktop, true, visitor_data) | ||||||
|  |                 .await; | ||||||
|             let request_body = QContinuation { |             let request_body = QContinuation { | ||||||
|  |                 context, | ||||||
|                 continuation: ctoken, |                 continuation: ctoken, | ||||||
|             }; |             }; | ||||||
| 
 | 
 | ||||||
|             let p = self |             let p = self | ||||||
|                 .execute_request_ctx::<response::Continuation, Paginator<YouTubeItem>, _>( |                 .execute_request::<response::Continuation, Paginator<YouTubeItem>, _>( | ||||||
|                     ClientType::Desktop, |                     ClientType::Desktop, | ||||||
|                     "continuation", |                     "continuation", | ||||||
|                     ctoken, |                     ctoken, | ||||||
|                     endpoint.as_str(), |                     endpoint.as_str(), | ||||||
|                     &request_body, |                     &request_body, | ||||||
|                     MapRespOptions { |  | ||||||
|                         visitor_data, |  | ||||||
|                         ..Default::default() |  | ||||||
|                     }, |  | ||||||
|                 ) |                 ) | ||||||
|                 .await?; |                 .await?; | ||||||
| 
 | 
 | ||||||
|             Ok(map_yt_paginator(p, endpoint)) |             Ok(map_yt_paginator(p, visitor_data, endpoint)) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_yt_paginator<T: FromYtItem>( | fn map_yt_paginator<T: FromYtItem>( | ||||||
|     p: Paginator<YouTubeItem>, |     p: Paginator<YouTubeItem>, | ||||||
|  |     visitor_data: Option<&str>, | ||||||
|     endpoint: ContinuationEndpoint, |     endpoint: ContinuationEndpoint, | ||||||
| ) -> Paginator<T> { | ) -> Paginator<T> { | ||||||
|     Paginator { |     Paginator { | ||||||
|         count: p.count, |         count: p.count, | ||||||
|         items: p.items.into_iter().filter_map(T::from_yt_item).collect(), |         items: p.items.into_iter().filter_map(T::from_yt_item).collect(), | ||||||
|         ctoken: p.ctoken, |         ctoken: p.ctoken, | ||||||
|         visitor_data: p.visitor_data, |         visitor_data: visitor_data.map(str::to_owned), | ||||||
|         endpoint, |         endpoint, | ||||||
|         authenticated: p.authenticated, |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_ytm_paginator<T: FromYtItem>( | fn map_ytm_paginator<T: FromYtItem>( | ||||||
|     p: Paginator<MusicItem>, |     p: Paginator<MusicItem>, | ||||||
|  |     visitor_data: Option<&str>, | ||||||
|     endpoint: ContinuationEndpoint, |     endpoint: ContinuationEndpoint, | ||||||
| ) -> Paginator<T> { | ) -> Paginator<T> { | ||||||
|     Paginator { |     Paginator { | ||||||
|         count: p.count, |         count: p.count, | ||||||
|         items: p.items.into_iter().filter_map(T::from_ytm_item).collect(), |         items: p.items.into_iter().filter_map(T::from_ytm_item).collect(), | ||||||
|         ctoken: p.ctoken, |         ctoken: p.ctoken, | ||||||
|         visitor_data: p.visitor_data, |         visitor_data: visitor_data.map(str::to_owned), | ||||||
|         endpoint, |         endpoint, | ||||||
|         authenticated: p.authenticated, |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn continuation_items(response: response::Continuation) -> MapResult<Vec<YouTubeListItem>> { |  | ||||||
|     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 { | impl MapResponse<Paginator<YouTubeItem>> for response::Continuation { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> { |     ) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> { | ||||||
|         let estimated_results = self.estimated_results; |         let items = self | ||||||
|         let items = continuation_items(self); |             .on_response_received_actions | ||||||
|  |             .and_then(|mut actions| { | ||||||
|  |                 actions | ||||||
|  |                     .try_swap_remove(0) | ||||||
|  |                     .map(|action| action.append_continuation_items_action.continuation_items) | ||||||
|  |             }) | ||||||
|  |             .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); |         mapper.map_response(items); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: Paginator::new_ext( |             c: Paginator::new(self.estimated_results, mapper.items, mapper.ctoken), | ||||||
|                 estimated_results, |  | ||||||
|                 mapper.items, |  | ||||||
|                 mapper.ctoken, |  | ||||||
|                 ctx.visitor_data.map(str::to_owned), |  | ||||||
|                 ContinuationEndpoint::Browse, |  | ||||||
|                 ctx.authenticated, |  | ||||||
|             ), |  | ||||||
|             warnings: mapper.warnings, |             warnings: mapper.warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  | @ -150,13 +124,11 @@ impl MapResponse<Paginator<YouTubeItem>> for response::Continuation { | ||||||
| impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation { | impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|     ) -> Result<MapResult<Paginator<MusicItem>>, ExtractionError> { |     ) -> Result<MapResult<Paginator<MusicItem>>, ExtractionError> { | ||||||
|         let mut mapper = if let Some(artist) = &ctx.artist { |         let mut mapper = MusicListMapper::new(lang); | ||||||
|             MusicListMapper::with_artist(ctx.lang, artist.clone()) |  | ||||||
|         } else { |  | ||||||
|             MusicListMapper::new(ctx.lang) |  | ||||||
|         }; |  | ||||||
|         let mut continuations = Vec::new(); |         let mut continuations = Vec::new(); | ||||||
| 
 | 
 | ||||||
|         match self.continuation_contents { |         match self.continuation_contents { | ||||||
|  | @ -174,11 +146,7 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation { | ||||||
|                         response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { |                         response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => { | ||||||
|                             mapper.map_response(shelf.contents); |                             mapper.map_response(shelf.contents); | ||||||
|                         } |                         } | ||||||
|                         response::music_item::ItemSection::GridRenderer(mut grid) => { |                         _ => {} | ||||||
|                             mapper.map_response(grid.items); |  | ||||||
|                             continuations.append(&mut grid.continuations); |  | ||||||
|                         } |  | ||||||
|                         response::music_item::ItemSection::None => {} |  | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  | @ -189,133 +157,20 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation { | ||||||
|                 mapper.add_warnings(&mut panel.contents.warnings); |                 mapper.add_warnings(&mut panel.contents.warnings); | ||||||
|                 panel.contents.c.into_iter().for_each(|item| { |                 panel.contents.c.into_iter().for_each(|item| { | ||||||
|                     if let PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) = item { |                     if let PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) = item { | ||||||
|                         let mut track = map_queue_item(item, ctx.lang); |                         mapper.add_item(MusicItem::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 => {} |             None => {} | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         for a in self.on_response_received_actions { |  | ||||||
|             mapper.map_response(a.append_continuation_items_action.continuation_items); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let ctoken = mapper.ctoken.clone().or_else(|| { |  | ||||||
|             continuations |  | ||||||
|                 .into_iter() |  | ||||||
|                 .next() |  | ||||||
|                 .map(|cont| cont.next_continuation_data.continuation) |  | ||||||
|         }); |  | ||||||
|         let map_res = mapper.items(); |         let map_res = mapper.items(); | ||||||
| 
 |  | ||||||
|         Ok(MapResult { |  | ||||||
|             c: Paginator::new_ext( |  | ||||||
|                 None, |  | ||||||
|                 map_res.c, |  | ||||||
|                 ctoken, |  | ||||||
|                 ctx.visitor_data.map(str::to_owned), |  | ||||||
|                 ContinuationEndpoint::MusicBrowse, |  | ||||||
|                 ctx.authenticated, |  | ||||||
|             ), |  | ||||||
|             warnings: map_res.warnings, |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(feature = "userdata")] |  | ||||||
| impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation { |  | ||||||
|     fn map_response( |  | ||||||
|         self, |  | ||||||
|         ctx: &MapRespCtx<'_>, |  | ||||||
|     ) -> Result<MapResult<Paginator<HistoryItem<VideoItem>>>, ExtractionError> { |  | ||||||
|         let mut map_res = MapResult::default(); |  | ||||||
|         let mut ctoken = None; |  | ||||||
| 
 |  | ||||||
|         let items = continuation_items(self); |  | ||||||
|         for item in items.c { |  | ||||||
|             match item { |  | ||||||
|                 response::YouTubeListItem::ItemSectionRenderer { header, contents } => { |  | ||||||
|                     let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang); |  | ||||||
|                     mapper.map_response(contents); |  | ||||||
|                     mapper.conv_history_items( |  | ||||||
|                         header.map(|h| h.item_section_header_renderer.title), |  | ||||||
|                         ctx.utc_offset, |  | ||||||
|                         &mut map_res, |  | ||||||
|                     ); |  | ||||||
|                 } |  | ||||||
|                 response::YouTubeListItem::ContinuationItemRenderer(ep) => { |  | ||||||
|                     if ctoken.is_none() { |  | ||||||
|                         ctoken = ep.continuation_endpoint.into_token(); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                 _ => {} |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         Ok(MapResult { |  | ||||||
|             c: Paginator::new_ext( |  | ||||||
|                 None, |  | ||||||
|                 map_res.c, |  | ||||||
|                 ctoken, |  | ||||||
|                 ctx.visitor_data.map(str::to_owned), |  | ||||||
|                 ContinuationEndpoint::Browse, |  | ||||||
|                 ctx.authenticated, |  | ||||||
|             ), |  | ||||||
|             warnings: map_res.warnings, |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(feature = "userdata")] |  | ||||||
| impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicContinuation { |  | ||||||
|     fn map_response( |  | ||||||
|         self, |  | ||||||
|         ctx: &MapRespCtx<'_>, |  | ||||||
|     ) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> { |  | ||||||
|         let mut map_res = MapResult::default(); |  | ||||||
|         let mut continuations = Vec::new(); |  | ||||||
| 
 |  | ||||||
|         let mut map_shelf = |shelf: response::music_item::MusicShelf| { |  | ||||||
|             let mut mapper = MusicListMapper::new(ctx.lang); |  | ||||||
|             mapper.map_response(shelf.contents); |  | ||||||
|             mapper.conv_history_items(shelf.title, ctx.utc_offset, &mut map_res); |  | ||||||
|             continuations.extend(shelf.continuations); |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         match self.continuation_contents { |  | ||||||
|             Some(response::music_item::ContinuationContents::MusicShelfContinuation(shelf)) => { |  | ||||||
|                 map_shelf(shelf); |  | ||||||
|             } |  | ||||||
|             Some(response::music_item::ContinuationContents::SectionListContinuation(contents)) => { |  | ||||||
|                 for c in contents.contents { |  | ||||||
|                     if let response::music_item::ItemSection::MusicShelfRenderer(shelf) = c { |  | ||||||
|                         map_shelf(shelf); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             _ => {} |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let ctoken = continuations |         let ctoken = continuations | ||||||
|             .into_iter() |             .try_swap_remove(0) | ||||||
|             .next() |  | ||||||
|             .map(|cont| cont.next_continuation_data.continuation); |             .map(|cont| cont.next_continuation_data.continuation); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: Paginator::new_ext( |             c: Paginator::new(None, map_res.c, ctoken), | ||||||
|                 None, |  | ||||||
|                 map_res.c, |  | ||||||
|                 ctoken, |  | ||||||
|                 ctx.visitor_data.map(str::to_owned), |  | ||||||
|                 ContinuationEndpoint::MusicBrowse, |  | ||||||
|                 ctx.authenticated, |  | ||||||
|             ), |  | ||||||
|             warnings: map_res.warnings, |             warnings: map_res.warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  | @ -325,18 +180,12 @@ impl<T: FromYtItem> Paginator<T> { | ||||||
|     /// Get the next page from the paginator (or `None` if the paginator is exhausted)
 |     /// Get the next page from the paginator (or `None` if the paginator is exhausted)
 | ||||||
|     pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> { |     pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> { | ||||||
|         Ok(match &self.ctoken { |         Ok(match &self.ctoken { | ||||||
|             Some(ctoken) => { |             Some(ctoken) => Some( | ||||||
|                 let q = if self.authenticated { |                 query | ||||||
|                     &query.as_ref().clone().authenticated() |                     .as_ref() | ||||||
|                 } else { |                     .continuation(ctoken, self.endpoint, self.visitor_data.as_deref()) | ||||||
|                     query.as_ref() |  | ||||||
|                 }; |  | ||||||
| 
 |  | ||||||
|                 Some( |  | ||||||
|                     q.continuation(ctoken, self.endpoint, self.visitor_data.as_deref()) |  | ||||||
|                     .await?, |                     .await?, | ||||||
|                 ) |             ), | ||||||
|             } |  | ||||||
|             _ => None, |             _ => None, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  | @ -350,9 +199,6 @@ impl<T: FromYtItem> Paginator<T> { | ||||||
|                 let mut items = paginator.items; |                 let mut items = paginator.items; | ||||||
|                 self.items.append(&mut items); |                 self.items.append(&mut items); | ||||||
|                 self.ctoken = paginator.ctoken; |                 self.ctoken = paginator.ctoken; | ||||||
|                 if paginator.visitor_data.is_some() { |  | ||||||
|                     self.visitor_data = paginator.visitor_data; |  | ||||||
|                 } |  | ||||||
|                 Ok(true) |                 Ok(true) | ||||||
|             } |             } | ||||||
|             Ok(None) => Ok(false), |             Ok(None) => Ok(false), | ||||||
|  | @ -395,19 +241,6 @@ impl<T: FromYtItem> Paginator<T> { | ||||||
|         } |         } | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     /// Extend the items of the paginator until the paginator is exhausted.
 |  | ||||||
|     pub async fn extend_all<Q: AsRef<RustyPipeQuery>>(&mut self, query: Q) -> Result<(), Error> { |  | ||||||
|         let query = query.as_ref(); |  | ||||||
|         loop { |  | ||||||
|             match self.extend(query).await { |  | ||||||
|                 Ok(false) => break, |  | ||||||
|                 Err(e) => return Err(e), |  | ||||||
|                 _ => {} |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl Paginator<Comment> { | impl Paginator<Comment> { | ||||||
|  | @ -425,36 +258,12 @@ impl Paginator<Comment> { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[cfg(feature = "userdata")] | impl Paginator<PlaylistVideo> { | ||||||
| #[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)
 |     /// Get the next page from the paginator (or `None` if the paginator is exhausted)
 | ||||||
|     pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> { |     pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> { | ||||||
|         Ok(match &self.ctoken { |         Ok(match &self.ctoken { | ||||||
|             Some(ctoken) => Some( |             Some(ctoken) => Some(query.as_ref().playlist_continuation(ctoken).await?), | ||||||
|                 query |             None => None, | ||||||
|                     .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, |  | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -474,9 +283,6 @@ macro_rules! paginator { | ||||||
|                         let mut items = paginator.items; |                         let mut items = paginator.items; | ||||||
|                         self.items.append(&mut items); |                         self.items.append(&mut items); | ||||||
|                         self.ctoken = paginator.ctoken; |                         self.ctoken = paginator.ctoken; | ||||||
|                         if paginator.visitor_data.is_some() { |  | ||||||
|                             self.visitor_data = paginator.visitor_data; |  | ||||||
|                         } |  | ||||||
|                         Ok(true) |                         Ok(true) | ||||||
|                     } |                     } | ||||||
|                     Ok(None) => Ok(false), |                     Ok(None) => Ok(false), | ||||||
|  | @ -519,33 +325,12 @@ macro_rules! paginator { | ||||||
|                 } |                 } | ||||||
|                 Ok(()) |                 Ok(()) | ||||||
|             } |             } | ||||||
| 
 |  | ||||||
|             /// Extend the items of the paginator until the paginator is exhausted.
 |  | ||||||
|             pub async fn extend_all<Q: AsRef<RustyPipeQuery>>( |  | ||||||
|                 &mut self, |  | ||||||
|                 query: Q, |  | ||||||
|             ) -> Result<(), Error> { |  | ||||||
|                 let query = query.as_ref(); |  | ||||||
|                 loop { |  | ||||||
|                     match self.extend(query).await { |  | ||||||
|                         Ok(false) => break, |  | ||||||
|                         Err(e) => return Err(e), |  | ||||||
|                         _ => {} |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                 Ok(()) |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| paginator!(Comment); | paginator!(Comment); | ||||||
| #[cfg(feature = "userdata")] | paginator!(PlaylistVideo); | ||||||
| #[cfg_attr(docsrs, doc(cfg(feature = "userdata")))] |  | ||||||
| paginator!(HistoryItem<VideoItem>); |  | ||||||
| #[cfg(feature = "userdata")] |  | ||||||
| #[cfg_attr(docsrs, doc(cfg(feature = "userdata")))] |  | ||||||
| paginator!(HistoryItem<TrackItem>); |  | ||||||
| 
 | 
 | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod tests { | mod tests { | ||||||
|  | @ -556,16 +341,15 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
|     use crate::{ |     use crate::{ | ||||||
|         model::{ |         model::{MusicPlaylistItem, PlaylistItem, TrackItem}, | ||||||
|             AlbumItem, ArtistItem, ChannelItem, MusicPlaylistItem, PlaylistItem, TrackItem, |         param::Language, | ||||||
|             VideoItem, |  | ||||||
|         }, |  | ||||||
|         util::tests::TESTFILES, |         util::tests::TESTFILES, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::search("search", path!("search" / "cont.json"))] |     #[case("search", path!("search" / "cont.json"))] | ||||||
|     #[case::recommendations("recommendations", path!("video_details" / "recommendations.json"))] |     #[case("startpage", path!("trends" / "startpage_cont.json"))] | ||||||
|  |     #[case("recommendations", path!("video_details" / "recommendations.json"))] | ||||||
|     fn map_continuation_items(#[case] name: &str, #[case] path: PathBuf) { |     fn map_continuation_items(#[case] name: &str, #[case] path: PathBuf) { | ||||||
|         let json_path = path!(*TESTFILES / path); |         let json_path = path!(*TESTFILES / path); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | @ -573,7 +357,7 @@ mod tests { | ||||||
|         let items: response::Continuation = |         let items: response::Continuation = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Paginator<YouTubeItem>> = |         let map_res: MapResult<Paginator<YouTubeItem>> = | ||||||
|             items.map_response(&MapRespCtx::test("")).unwrap(); |             items.map_response("", Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -586,31 +370,7 @@ mod tests { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::channel_videos("channel_videos", path!("channel" / "channel_videos_cont.json"))] |     #[case("channel_playlists", path!("channel" / "channel_playlists_cont.json"))] | ||||||
|     #[case::playlist("playlist", path!("playlist" / "playlist_cont.json"))] |  | ||||||
|     fn map_continuation_videos(#[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<VideoItem> = |  | ||||||
|             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, { |  | ||||||
|             ".items[].publish_date" => "[date]", |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |  | ||||||
|     #[case::channel_playlists("channel_playlists", path!("channel" / "channel_playlists_cont.json"))] |  | ||||||
|     fn map_continuation_playlists(#[case] name: &str, #[case] path: PathBuf) { |     fn map_continuation_playlists(#[case] name: &str, #[case] path: PathBuf) { | ||||||
|         let json_path = path!(*TESTFILES / path); |         let json_path = path!(*TESTFILES / path); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | @ -618,9 +378,9 @@ mod tests { | ||||||
|         let items: response::Continuation = |         let items: response::Continuation = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Paginator<YouTubeItem>> = |         let map_res: MapResult<Paginator<YouTubeItem>> = | ||||||
|             items.map_response(&MapRespCtx::test("")).unwrap(); |             items.map_response("", Language::En, None).unwrap(); | ||||||
|         let paginator: Paginator<PlaylistItem> = |         let paginator: Paginator<PlaylistItem> = | ||||||
|             map_yt_paginator(map_res.c, ContinuationEndpoint::Browse); |             map_yt_paginator(map_res.c, None, ContinuationEndpoint::Browse); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -631,31 +391,9 @@ mod tests { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::subscriptions("subscriptions", path!("userdata" / "subscriptions.json"))] |     #[case("playlist_tracks", path!("music_playlist" / "playlist_cont.json"))] | ||||||
|     fn map_continuation_channels(#[case] name: &str, #[case] path: PathBuf) { |     #[case("search_tracks", path!("music_search" / "tracks_cont.json"))] | ||||||
|         let json_path = path!(*TESTFILES / path); |     #[case("radio_tracks", path!("music_details" / "radio_cont.json"))] | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
| 
 |  | ||||||
|         let items: response::Continuation = |  | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let map_res: MapResult<Paginator<YouTubeItem>> = |  | ||||||
|             items.map_response(&MapRespCtx::test("")).unwrap(); |  | ||||||
|         let paginator: Paginator<ChannelItem> = |  | ||||||
|             map_yt_paginator(map_res.c, ContinuationEndpoint::Browse); |  | ||||||
| 
 |  | ||||||
|         assert!( |  | ||||||
|             map_res.warnings.is_empty(), |  | ||||||
|             "deserialization/mapping warnings: {:?}", |  | ||||||
|             map_res.warnings |  | ||||||
|         ); |  | ||||||
|         insta::assert_ron_snapshot!(format!("map_{name}"), paginator); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |  | ||||||
|     #[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) { |     fn map_continuation_tracks(#[case] name: &str, #[case] path: PathBuf) { | ||||||
|         let json_path = path!(*TESTFILES / path); |         let json_path = path!(*TESTFILES / path); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | @ -663,9 +401,9 @@ mod tests { | ||||||
|         let items: response::MusicContinuation = |         let items: response::MusicContinuation = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Paginator<MusicItem>> = |         let map_res: MapResult<Paginator<MusicItem>> = | ||||||
|             items.map_response(&MapRespCtx::test("")).unwrap(); |             items.map_response("", Language::En, None).unwrap(); | ||||||
|         let paginator: Paginator<TrackItem> = |         let paginator: Paginator<TrackItem> = | ||||||
|             map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse); |             map_ytm_paginator(map_res.c, None, ContinuationEndpoint::MusicBrowse); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -676,50 +414,7 @@ mod tests { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::saved_artists("saved_artists", path!("music_userdata" / "saved_artists.json"))] |     #[case("playlist_related", path!("music_playlist" / "playlist_related.json"))] | ||||||
|     fn map_continuation_artists(#[case] name: &str, #[case] path: PathBuf) { |  | ||||||
|         let json_path = path!(*TESTFILES / path); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
| 
 |  | ||||||
|         let items: response::MusicContinuation = |  | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let map_res: MapResult<Paginator<MusicItem>> = |  | ||||||
|             items.map_response(&MapRespCtx::test("")).unwrap(); |  | ||||||
|         let paginator: Paginator<ArtistItem> = |  | ||||||
|             map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse); |  | ||||||
| 
 |  | ||||||
|         assert!( |  | ||||||
|             map_res.warnings.is_empty(), |  | ||||||
|             "deserialization/mapping warnings: {:?}", |  | ||||||
|             map_res.warnings |  | ||||||
|         ); |  | ||||||
|         insta::assert_ron_snapshot!(format!("map_{name}"), paginator); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |  | ||||||
|     #[case::saved_albums("saved_albums", path!("music_userdata" / "saved_albums.json"))] |  | ||||||
|     fn map_continuation_albums(#[case] name: &str, #[case] path: PathBuf) { |  | ||||||
|         let json_path = path!(*TESTFILES / path); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
| 
 |  | ||||||
|         let items: response::MusicContinuation = |  | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let map_res: MapResult<Paginator<MusicItem>> = |  | ||||||
|             items.map_response(&MapRespCtx::test("")).unwrap(); |  | ||||||
|         let paginator: Paginator<AlbumItem> = |  | ||||||
|             map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse); |  | ||||||
| 
 |  | ||||||
|         assert!( |  | ||||||
|             map_res.warnings.is_empty(), |  | ||||||
|             "deserialization/mapping warnings: {:?}", |  | ||||||
|             map_res.warnings |  | ||||||
|         ); |  | ||||||
|         insta::assert_ron_snapshot!(format!("map_{name}"), paginator); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[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) { |     fn map_continuation_music_playlists(#[case] name: &str, #[case] path: PathBuf) { | ||||||
|         let json_path = path!(*TESTFILES / path); |         let json_path = path!(*TESTFILES / path); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | @ -727,9 +422,9 @@ mod tests { | ||||||
|         let items: response::MusicContinuation = |         let items: response::MusicContinuation = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<Paginator<MusicItem>> = |         let map_res: MapResult<Paginator<MusicItem>> = | ||||||
|             items.map_response(&MapRespCtx::test("")).unwrap(); |             items.map_response("", Language::En, None).unwrap(); | ||||||
|         let paginator: Paginator<MusicPlaylistItem> = |         let paginator: Paginator<MusicPlaylistItem> = | ||||||
|             map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse); |             map_ytm_paginator(map_res.c, None, ContinuationEndpoint::MusicBrowse); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
							
								
								
									
										1029
									
								
								src/client/player.rs
									
										
									
									
									
								
							
							
						
						|  | @ -1,26 +1,23 @@ | ||||||
| use std::{borrow::Cow, convert::TryFrom, fmt::Debug}; | use std::{borrow::Cow, convert::TryFrom}; | ||||||
| 
 | 
 | ||||||
| use time::OffsetDateTime; | use time::OffsetDateTime; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{ |     model::{paginator::Paginator, ChannelId, Playlist, PlaylistVideo}, | ||||||
|         paginator::{ContinuationEndpoint, Paginator}, |     timeago, | ||||||
|         richtext::RichText, |     util::{self, TryRemove}, | ||||||
|         ChannelId, Playlist, VideoItem, |  | ||||||
|     }, |  | ||||||
|     serializer::text::{TextComponent, TextComponents}, |  | ||||||
|     util::{self, dictionary, timeago, TryRemove}, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{response, ClientType, MapRespCtx, MapResponse, MapResult, QBrowse, RustyPipeQuery}; | use super::{response, ClientType, MapResponse, MapResult, QBrowse, QContinuation, RustyPipeQuery}; | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Get a YouTube playlist
 |     /// Get a YouTube playlist
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn playlist<S: AsRef<str>>(&self, playlist_id: S) -> Result<Playlist, Error> { | ||||||
|     pub async fn playlist<S: AsRef<str> + Debug>(&self, playlist_id: S) -> Result<Playlist, Error> { |  | ||||||
|         let playlist_id = playlist_id.as_ref(); |         let playlist_id = playlist_id.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::Desktop, true, None).await; | ||||||
|         let request_body = QBrowse { |         let request_body = QBrowse { | ||||||
|  |             context, | ||||||
|             browse_id: &format!("VL{playlist_id}"), |             browse_id: &format!("VL{playlist_id}"), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -33,19 +30,46 @@ impl RustyPipeQuery { | ||||||
|         ) |         ) | ||||||
|         .await |         .await | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     /// Get more playlist items using the given continuation token
 | ||||||
|  |     pub async fn playlist_continuation<S: AsRef<str>>( | ||||||
|  |         &self, | ||||||
|  |         ctoken: S, | ||||||
|  |     ) -> Result<Paginator<PlaylistVideo>, Error> { | ||||||
|  |         let ctoken = ctoken.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::Desktop, true, None).await; | ||||||
|  |         let request_body = QContinuation { | ||||||
|  |             context, | ||||||
|  |             continuation: ctoken, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         self.execute_request::<response::PlaylistCont, _, _>( | ||||||
|  |             ClientType::Desktop, | ||||||
|  |             "playlist_continuation", | ||||||
|  |             ctoken, | ||||||
|  |             "browse", | ||||||
|  |             &request_body, | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MapResponse<Playlist> for response::Playlist { | impl MapResponse<Playlist> for response::Playlist { | ||||||
|     fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<Playlist>, ExtractionError> { |     fn map_response( | ||||||
|         let (Some(contents), Some(header)) = (self.contents, self.header) else { |         self, | ||||||
|             return Err(response::alerts_to_err(ctx.id, self.alerts)); |         id: &str, | ||||||
|  |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<Playlist>, ExtractionError> { | ||||||
|  |         let (contents, header) = match (self.contents, self.header) { | ||||||
|  |             (Some(contents), Some(header)) => (contents, header), | ||||||
|  |             _ => return Err(response::alerts_to_err(self.alerts)), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let video_items = contents |         let mut tcbr_contents = contents.two_column_browse_results_renderer.contents; | ||||||
|             .two_column_browse_results_renderer | 
 | ||||||
|             .contents |         let video_items = tcbr_contents | ||||||
|             .into_iter() |             .try_swap_remove(0) | ||||||
|             .next() |  | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed( |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed( | ||||||
|                 "twoColumnBrowseResultsRenderer empty", |                 "twoColumnBrowseResultsRenderer empty", | ||||||
|             )))? |             )))? | ||||||
|  | @ -53,193 +77,158 @@ impl MapResponse<Playlist> for response::Playlist { | ||||||
|             .content |             .content | ||||||
|             .section_list_renderer |             .section_list_renderer | ||||||
|             .contents |             .contents | ||||||
|             .into_iter() |             .try_swap_remove(0) | ||||||
|             .next() |  | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed( |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed( | ||||||
|                 "sectionListRenderer empty", |                 "sectionListRenderer empty", | ||||||
|             )))? |             )))? | ||||||
|             .item_section_renderer |             .item_section_renderer | ||||||
|             .contents |             .contents | ||||||
|             .into_iter() |             .try_swap_remove(0) | ||||||
|             .next() |  | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed( |             .ok_or(ExtractionError::InvalidData(Cow::Borrowed( | ||||||
|                 "itemSectionRenderer empty", |                 "itemSectionRenderer empty", | ||||||
|             )))? |             )))? | ||||||
|             .playlist_video_list_renderer |             .playlist_video_list_renderer | ||||||
|             .contents; |             .contents; | ||||||
| 
 | 
 | ||||||
|         let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang); |         let (videos, ctoken) = map_playlist_items(video_items.c); | ||||||
|         mapper.map_response(video_items); |  | ||||||
| 
 | 
 | ||||||
|         let (description, thumbnails, last_update_txt) = match self.sidebar { |         let (thumbnails, last_update_txt) = match self.sidebar { | ||||||
|             Some(sidebar) => { |             Some(sidebar) => { | ||||||
|                 let sidebar_items = sidebar.playlist_sidebar_renderer.contents; |                 let mut sidebar_items = sidebar.playlist_sidebar_renderer.items; | ||||||
|                 let mut primary = |                 let mut primary = | ||||||
|                     sidebar_items |                     sidebar_items | ||||||
|                         .into_iter() |                         .try_swap_remove(0) | ||||||
|                         .next() |  | ||||||
|                         .ok_or(ExtractionError::InvalidData(Cow::Borrowed( |                         .ok_or(ExtractionError::InvalidData(Cow::Borrowed( | ||||||
|                             "no primary sidebar", |                             "no primary sidebar", | ||||||
|                         )))?; |                         )))?; | ||||||
| 
 | 
 | ||||||
|                 ( |                 ( | ||||||
|                     primary |  | ||||||
|                         .playlist_sidebar_primary_info_renderer |  | ||||||
|                         .description |  | ||||||
|                         .filter(|d| !d.0.is_empty()), |  | ||||||
|                     Some( |  | ||||||
|                     primary |                     primary | ||||||
|                         .playlist_sidebar_primary_info_renderer |                         .playlist_sidebar_primary_info_renderer | ||||||
|                         .thumbnail_renderer |                         .thumbnail_renderer | ||||||
|                         .playlist_video_thumbnail_renderer |                         .playlist_video_thumbnail_renderer | ||||||
|                         .thumbnail, |                         .thumbnail, | ||||||
|                     ), |  | ||||||
|                     primary |                     primary | ||||||
|                         .playlist_sidebar_primary_info_renderer |                         .playlist_sidebar_primary_info_renderer | ||||||
|                         .stats |                         .stats | ||||||
|                         .try_swap_remove(2), |                         .try_swap_remove(2), | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|             None => (None, None, None), |             None => { | ||||||
|         }; |                 let header_banner = header | ||||||
|  |                     .playlist_header_renderer | ||||||
|  |                     .playlist_header_banner | ||||||
|  |                     .ok_or(ExtractionError::InvalidData(Cow::Borrowed( | ||||||
|  |                         "no thumbnail found", | ||||||
|  |                     )))?; | ||||||
| 
 | 
 | ||||||
|         let (name, playlist_id, channel, n_videos_txt, description2, thumbnails2, last_update_txt2) = |                 let mut byline = header.playlist_header_renderer.byline; | ||||||
|             match header { |  | ||||||
|                 response::playlist::Header::PlaylistHeaderRenderer(header_renderer) => { |  | ||||||
|                     let mut byline = header_renderer.byline; |  | ||||||
|                 let last_update_txt = byline |                 let last_update_txt = byline | ||||||
|                     .try_swap_remove(1) |                     .try_swap_remove(1) | ||||||
|                     .map(|b| b.playlist_byline_renderer.text); |                     .map(|b| b.playlist_byline_renderer.text); | ||||||
| 
 | 
 | ||||||
|                 ( |                 ( | ||||||
|                         header_renderer.title, |                     header_banner.hero_playlist_thumbnail_renderer.thumbnail, | ||||||
|                         header_renderer.playlist_id, |  | ||||||
|                         header_renderer |  | ||||||
|                             .owner_text |  | ||||||
|                             .and_then(|link| ChannelId::try_from(link).ok()), |  | ||||||
|                         header_renderer.num_videos_text, |  | ||||||
|                         header_renderer |  | ||||||
|                             .description_text |  | ||||||
|                             .map(|text| TextComponents(vec![TextComponent::new(text)])), |  | ||||||
|                         header_renderer |  | ||||||
|                             .playlist_header_banner |  | ||||||
|                             .map(|b| b.hero_playlist_thumbnail_renderer.thumbnail), |  | ||||||
|                     last_update_txt, |                     last_update_txt, | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|                 response::playlist::Header::PageHeaderRenderer(content_renderer) => { |  | ||||||
|                     let h = content_renderer.content.page_header_view_model; |  | ||||||
|                     let rows = h.metadata.content_metadata_view_model.metadata_rows; |  | ||||||
|                     let n_videos_txt = rows |  | ||||||
|                         .get(1) |  | ||||||
|                         .and_then(|r| r.metadata_parts.get(1)) |  | ||||||
|                         .map(|p| p.as_str().to_owned()) |  | ||||||
|                         .ok_or(ExtractionError::InvalidData("no video count".into()))?; |  | ||||||
|                     let mut channel = rows |  | ||||||
|                         .into_iter() |  | ||||||
|                         .next() |  | ||||||
|                         .and_then(|r| r.metadata_parts.into_iter().next()) |  | ||||||
|                         .and_then(|p| match p { |  | ||||||
|                             response::MetadataPart::Text { .. } => None, |  | ||||||
|                             response::MetadataPart::AvatarStack { avatar_stack } => { |  | ||||||
|                                 ChannelId::try_from(avatar_stack.avatar_stack_view_model.text).ok() |  | ||||||
|                             } |  | ||||||
|                         }); |  | ||||||
|                     // remove "by" prefix
 |  | ||||||
|                     if let Some(c) = channel.as_mut() { |  | ||||||
|                         let entry = dictionary::entry(ctx.lang); |  | ||||||
|                         let n = c.name.strip_prefix(entry.chan_prefix).unwrap_or(&c.name); |  | ||||||
|                         let n = n.strip_suffix(entry.chan_suffix).unwrap_or(n); |  | ||||||
|                         c.name = n.trim().to_owned(); |  | ||||||
|                     } |  | ||||||
| 
 |  | ||||||
|                     let playlist_id = h |  | ||||||
|                         .actions |  | ||||||
|                         .flexible_actions_view_model |  | ||||||
|                         .actions_rows |  | ||||||
|                         .into_iter() |  | ||||||
|                         .next() |  | ||||||
|                         .and_then(|r| r.actions.into_iter().next()) |  | ||||||
|                         .and_then(|a| { |  | ||||||
|                             a.button_view_model |  | ||||||
|                                 .on_tap |  | ||||||
|                                 .innertube_command |  | ||||||
|                                 .into_playlist_id() |  | ||||||
|                         }) |  | ||||||
|                         .ok_or(ExtractionError::InvalidData("no playlist id".into()))?; |  | ||||||
|                     ( |  | ||||||
|                         h.title.dynamic_text_view_model.text, |  | ||||||
|                         playlist_id, |  | ||||||
|                         channel, |  | ||||||
|                         n_videos_txt, |  | ||||||
|                         h.description.description_preview_view_model.description, |  | ||||||
|                         h.hero_image.content_preview_image_view_model.image.into(), |  | ||||||
|                         None, |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let n_videos = if mapper.ctoken.is_some() { |         let n_videos = match ctoken { | ||||||
|             util::parse_numeric(&n_videos_txt) |             Some(_) => util::parse_numeric(&header.playlist_header_renderer.num_videos_text) | ||||||
|                 .map_err(|_| ExtractionError::InvalidData("no video count".into()))? |                 .map_err(|_| ExtractionError::InvalidData(Cow::Borrowed("no video count")))?, | ||||||
|         } else { |             None => videos.len() as u64, | ||||||
|             mapper.items.len() as u64 |  | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         if playlist_id != ctx.id { |         let playlist_id = header.playlist_header_renderer.playlist_id; | ||||||
|  |         if playlist_id != id { | ||||||
|             return Err(ExtractionError::WrongResult(format!( |             return Err(ExtractionError::WrongResult(format!( | ||||||
|                 "got wrong playlist id {}, expected {}", |                 "got wrong playlist id {playlist_id}, expected {id}" | ||||||
|                 playlist_id, ctx.id |  | ||||||
|             ))); |             ))); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let description = description.or(description2).map(RichText::from); |         let name = header.playlist_header_renderer.title; | ||||||
|         let thumbnails = thumbnails |         let description = header.playlist_header_renderer.description_text; | ||||||
|             .or(thumbnails2) |         let channel = header | ||||||
|             .ok_or(ExtractionError::InvalidData(Cow::Borrowed( |             .playlist_header_renderer | ||||||
|                 "no thumbnail found", |             .owner_text | ||||||
|             )))?; |             .and_then(|link| ChannelId::try_from(link).ok()); | ||||||
|         let last_update = last_update_txt | 
 | ||||||
|             .as_deref() |         let mut warnings = video_items.warnings; | ||||||
|             .or(last_update_txt2.as_deref()) |         let last_update = last_update_txt.as_ref().and_then(|txt| { | ||||||
|             .and_then(|txt| { |             timeago::parse_textual_date_or_warn(lang, txt, &mut warnings).map(OffsetDateTime::date) | ||||||
|                 timeago::parse_textual_date_or_warn( |  | ||||||
|                     ctx.lang, |  | ||||||
|                     ctx.utc_offset, |  | ||||||
|                     txt, |  | ||||||
|                     &mut mapper.warnings, |  | ||||||
|                 ) |  | ||||||
|                 .map(OffsetDateTime::date) |  | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: Playlist { |             c: Playlist { | ||||||
|                 id: playlist_id, |                 id: playlist_id, | ||||||
|                 name, |                 name, | ||||||
|                 videos: Paginator::new_ext( |                 videos: Paginator::new(Some(n_videos), videos, ctoken), | ||||||
|                     Some(n_videos), |  | ||||||
|                     mapper.items, |  | ||||||
|                     mapper.ctoken, |  | ||||||
|                     ctx.visitor_data.map(str::to_owned), |  | ||||||
|                     ContinuationEndpoint::Browse, |  | ||||||
|                     ctx.authenticated, |  | ||||||
|                 ), |  | ||||||
|                 video_count: n_videos, |                 video_count: n_videos, | ||||||
|                 thumbnail: thumbnails.into(), |                 thumbnail: thumbnails.into(), | ||||||
|                 description, |                 description, | ||||||
|                 channel, |                 channel, | ||||||
|                 last_update, |                 last_update, | ||||||
|                 last_update_txt, |                 last_update_txt, | ||||||
|                 visitor_data: self |                 visitor_data: self.response_context.visitor_data, | ||||||
|                     .response_context |  | ||||||
|                     .visitor_data |  | ||||||
|                     .or_else(|| ctx.visitor_data.map(str::to_owned)), |  | ||||||
|             }, |             }, | ||||||
|             warnings: mapper.warnings, |             warnings, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont { | ||||||
|  |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         _id: &str, | ||||||
|  |         _lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<Paginator<PlaylistVideo>>, ExtractionError> { | ||||||
|  |         let action = self.on_response_received_actions.into_iter().next(); | ||||||
|  | 
 | ||||||
|  |         let ((items, ctoken), warnings) = action | ||||||
|  |             .map(|action| { | ||||||
|  |                 ( | ||||||
|  |                     map_playlist_items( | ||||||
|  |                         action.append_continuation_items_action.continuation_items.c, | ||||||
|  |                     ), | ||||||
|  |                     action | ||||||
|  |                         .append_continuation_items_action | ||||||
|  |                         .continuation_items | ||||||
|  |                         .warnings, | ||||||
|  |                 ) | ||||||
|  |             }) | ||||||
|  |             .unwrap_or_default(); | ||||||
|  | 
 | ||||||
|  |         Ok(MapResult { | ||||||
|  |             c: Paginator::new(None, items, ctoken), | ||||||
|  |             warnings, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | fn map_playlist_items( | ||||||
|  |     items: Vec<response::playlist::PlaylistItem>, | ||||||
|  | ) -> (Vec<PlaylistVideo>, Option<String>) { | ||||||
|  |     let mut ctoken: Option<String> = None; | ||||||
|  |     let videos = items | ||||||
|  |         .into_iter() | ||||||
|  |         .filter_map(|it| match it { | ||||||
|  |             response::playlist::PlaylistItem::PlaylistVideoRenderer(video) => { | ||||||
|  |                 PlaylistVideo::try_from(video).ok() | ||||||
|  |             } | ||||||
|  |             response::playlist::PlaylistItem::ContinuationItemRenderer { | ||||||
|  |                 continuation_endpoint, | ||||||
|  |             } => { | ||||||
|  |                 ctoken = Some(continuation_endpoint.continuation_command.token); | ||||||
|  |                 None | ||||||
|  |             } | ||||||
|  |             response::playlist::PlaylistItem::None => None, | ||||||
|  |         }) | ||||||
|  |         .collect::<Vec<_>>(); | ||||||
|  |     (videos, ctoken) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod tests { | mod tests { | ||||||
|     use std::{fs::File, io::BufReader}; |     use std::{fs::File, io::BufReader}; | ||||||
|  | @ -247,7 +236,7 @@ mod tests { | ||||||
|     use path_macro::path; |     use path_macro::path; | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use crate::util::tests::TESTFILES; |     use crate::{param::Language, util::tests::TESTFILES}; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
| 
 | 
 | ||||||
|  | @ -255,16 +244,13 @@ mod tests { | ||||||
|     #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] |     #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] | ||||||
|     #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] |     #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] | ||||||
|     #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] |     #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] | ||||||
|     #[case::live("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw")] |  | ||||||
|     #[case::pageheader("20241011_pageheader", "PLT2w2oBf1TZKyvY_M6JsASs73m-wjLzH5")] |  | ||||||
|     #[case::cmdexecutor("20250316_cmdexecutor", "PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")] |  | ||||||
|     fn map_playlist_data(#[case] name: &str, #[case] id: &str) { |     fn map_playlist_data(#[case] name: &str, #[case] id: &str) { | ||||||
|         let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json")); |         let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json")); | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let playlist: response::Playlist = |         let playlist: response::Playlist = | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res = playlist.map_response(&MapRespCtx::test(id)).unwrap(); |         let map_res = playlist.map_response(id, Language::En, None).unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  | @ -272,8 +258,24 @@ mod tests { | ||||||
|             map_res.warnings |             map_res.warnings | ||||||
|         ); |         ); | ||||||
|         insta::assert_ron_snapshot!(format!("map_playlist_data_{name}"), map_res.c, { |         insta::assert_ron_snapshot!(format!("map_playlist_data_{name}"), map_res.c, { | ||||||
|             ".last_update" => "[date]", |             ".last_update" => "[date]" | ||||||
|             ".videos.items[].publish_date" => "[date]", |  | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     #[test] | ||||||
|  |     fn map_playlist_cont() { | ||||||
|  |         let json_path = path!(*TESTFILES / "playlist" / "playlist_cont.json"); | ||||||
|  |         let json_file = File::open(json_path).unwrap(); | ||||||
|  | 
 | ||||||
|  |         let playlist: response::PlaylistCont = | ||||||
|  |             serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|  |         let map_res = playlist.map_response("", Language::En, None).unwrap(); | ||||||
|  | 
 | ||||||
|  |         assert!( | ||||||
|  |             map_res.warnings.is_empty(), | ||||||
|  |             "deserialization/mapping warnings: {:?}", | ||||||
|  |             map_res.warnings | ||||||
|  |         ); | ||||||
|  |         insta::assert_ron_snapshot!("map_playlist_cont", map_res.c); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,14 +2,10 @@ use serde::Deserialize; | ||||||
| use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError}; | use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError}; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     video_item::YouTubeListRenderer, Alert, AttachmentRun, AvatarViewModel, ChannelBadge, |     video_item::YouTubeListRenderer, Alert, ChannelBadge, ContentsRenderer, ResponseContext, | ||||||
|     ContentRenderer, ContentsRenderer, ContinuationActionWrap, ImageView, |     Thumbnails, | ||||||
|     PageHeaderRendererContent, PhMetadataView, ResponseContext, Thumbnails, TwoColumnBrowseResults, |  | ||||||
| }; |  | ||||||
| use crate::{ |  | ||||||
|     model::Verification, |  | ||||||
|     serializer::text::{AttributedText, Text, TextComponent}, |  | ||||||
| }; | }; | ||||||
|  | use crate::serializer::text::Text; | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -26,7 +22,21 @@ pub(crate) struct Channel { | ||||||
|     pub response_context: ResponseContext, |     pub response_context: ResponseContext, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub(crate) type Contents = TwoColumnBrowseResults<TabRendererWrap>; | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Contents { | ||||||
|  |     pub two_column_browse_results_renderer: TabsRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// YouTube channel tab view. Contains multiple tabs
 | ||||||
|  | /// (Home, Videos, Playlists, About...). We can ignore unknown tabs.
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct TabsRenderer { | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub tabs: Vec<TabRendererWrap>, | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -40,7 +50,7 @@ pub(crate) struct TabRendererWrap { | ||||||
| pub(crate) struct TabRenderer { | pub(crate) struct TabRenderer { | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub content: TabContent, |     pub content: TabContent, | ||||||
|     pub endpoint: Option<ChannelTabEndpoint>, |     pub endpoint: ChannelTabEndpoint, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -75,12 +85,10 @@ pub(crate) struct ChannelTabWebCommandMetadata { | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| #[allow(clippy::enum_variant_names)] |  | ||||||
| pub(crate) enum Header { | pub(crate) enum Header { | ||||||
|     C4TabbedHeaderRenderer(HeaderRenderer), |     C4TabbedHeaderRenderer(HeaderRenderer), | ||||||
|     /// Used for special channels like YouTube Music
 |     /// Used for special channels like YouTube Music
 | ||||||
|     CarouselHeaderRenderer(ContentsRenderer<CarouselHeaderRendererItem>), |     CarouselHeaderRenderer(ContentsRenderer<CarouselHeaderRendererItem>), | ||||||
|     PageHeaderRenderer(ContentRenderer<PageHeaderRendererContent<PageHeaderRendererInner>>), |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -99,6 +107,11 @@ pub(crate) struct HeaderRenderer { | ||||||
|     pub badges: Vec<ChannelBadge>, |     pub badges: Vec<ChannelBadge>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub banner: Thumbnails, |     pub banner: Thumbnails, | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub mobile_banner: Thumbnails, | ||||||
|  |     /// Fullscreen (16:9) channel banner
 | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub tv_banner: Thumbnails, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -109,8 +122,6 @@ pub(crate) enum CarouselHeaderRendererItem { | ||||||
|     TopicChannelDetailsRenderer { |     TopicChannelDetailsRenderer { | ||||||
|         #[serde_as(as = "Option<Text>")] |         #[serde_as(as = "Option<Text>")] | ||||||
|         subscriber_count_text: Option<String>, |         subscriber_count_text: Option<String>, | ||||||
|         #[serde_as(as = "Option<Text>")] |  | ||||||
|         subtitle: Option<String>, |  | ||||||
|         #[serde(default)] |         #[serde(default)] | ||||||
|         avatar: Thumbnails, |         avatar: Thumbnails, | ||||||
|     }, |     }, | ||||||
|  | @ -118,59 +129,6 @@ pub(crate) enum CarouselHeaderRendererItem { | ||||||
|     None, |     None, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PageHeaderRendererInner { |  | ||||||
|     /// Channel title (only used to extract verification badges)
 |  | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub title: Option<PhTitleView>, |  | ||||||
|     /// Channel avatar
 |  | ||||||
|     pub image: PhAvatarView, |  | ||||||
|     /// Channel metadata (subscribers, video count)
 |  | ||||||
|     pub metadata: PhMetadataView, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub banner: PhBannerView, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PhTitleView { |  | ||||||
|     pub dynamic_text_view_model: PhTitleView2, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PhTitleView2 { |  | ||||||
|     pub text: PhTitleView3, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Default, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PhTitleView3 { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub attachment_runs: Vec<AttachmentRun>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PhAvatarView { |  | ||||||
|     pub decorated_avatar_view_model: PhAvatarView2, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PhAvatarView2 { |  | ||||||
|     pub avatar: AvatarViewModel, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PhBannerView { |  | ||||||
|     pub image_banner_view_model: ImageView, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Metadata { | pub(crate) struct Metadata { | ||||||
|  | @ -199,85 +157,3 @@ pub(crate) struct MicroformatDataRenderer { | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub tags: Vec<String>, |     pub tags: Vec<String>, | ||||||
| } | } | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(untagged)] |  | ||||||
| pub(crate) enum ChannelAbout { |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     ReceivedEndpoints { |  | ||||||
|         #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|         on_response_received_endpoints: Vec<ContinuationActionWrap<AboutChannelRendererWrap>>, |  | ||||||
|     }, |  | ||||||
|     Content { |  | ||||||
|         contents: Option<Contents>, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AboutChannelRendererWrap { |  | ||||||
|     pub about_channel_renderer: AboutChannelRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AboutChannelRenderer { |  | ||||||
|     pub metadata: ChannelMetadata, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ChannelMetadata { |  | ||||||
|     pub about_channel_view_model: ChannelMetadataView, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ChannelMetadataView { |  | ||||||
|     pub channel_id: String, |  | ||||||
|     pub canonical_channel_url: String, |  | ||||||
|     pub country: Option<String>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub description: String, |  | ||||||
|     #[serde_as(as = "Option<Text>")] |  | ||||||
|     pub joined_date_text: Option<String>, |  | ||||||
|     #[serde_as(as = "Option<Text>")] |  | ||||||
|     pub subscriber_count_text: Option<String>, |  | ||||||
|     #[serde_as(as = "Option<Text>")] |  | ||||||
|     pub video_count_text: Option<String>, |  | ||||||
|     #[serde_as(as = "Option<Text>")] |  | ||||||
|     pub view_count_text: Option<String>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub links: Vec<ExternalLink>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ExternalLink { |  | ||||||
|     pub channel_external_link_view_model: ExternalLinkInner, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ExternalLinkInner { |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub title: TextComponent, |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub link: TextComponent, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl From<PhTitleView> for crate::model::Verification { |  | ||||||
|     fn from(value: PhTitleView) -> Self { |  | ||||||
|         value |  | ||||||
|             .dynamic_text_view_model |  | ||||||
|             .text |  | ||||||
|             .attachment_runs |  | ||||||
|             .into_iter() |  | ||||||
|             .next() |  | ||||||
|             .map(Verification::from) |  | ||||||
|             .unwrap_or_default() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,6 +1,8 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use time::OffsetDateTime; | use time::OffsetDateTime; | ||||||
| 
 | 
 | ||||||
|  | use crate::util; | ||||||
|  | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| pub(crate) struct ChannelRss { | pub(crate) struct ChannelRss { | ||||||
|     #[serde(rename = "channelId")] |     #[serde(rename = "channelId")] | ||||||
|  | @ -78,3 +80,54 @@ 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| { | ||||||
|  |                     if !entry.channel_id.is_empty() { | ||||||
|  |                         Some(entry.channel_id.to_owned()) | ||||||
|  |                     } else { | ||||||
|  |                         None | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |                 .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, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										202
									
								
								src/client/response/channel_tv.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,202 @@ | ||||||
|  | use serde::Deserialize; | ||||||
|  | use serde_with::{serde_as, VecSkipError}; | ||||||
|  | 
 | ||||||
|  | use super::{ | ||||||
|  |     url_endpoint::NavigationEndpoint, video_item::TimeOverlay, ContentRenderer, ResponseContext, | ||||||
|  |     Thumbnails, | ||||||
|  | }; | ||||||
|  | use crate::serializer::{text::Text, MapResult, VecLogError}; | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct ChannelTv { | ||||||
|  |     pub contents: Contents, | ||||||
|  |     pub response_context: ResponseContext, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Contents { | ||||||
|  |     pub tv_browse_renderer: ContentRenderer<TvSurface>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct TvSurface { | ||||||
|  |     pub tv_surface_content_renderer: SurfaceContentRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SurfaceContentRenderer { | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub content: SurfaceContent, | ||||||
|  |     pub header: SurfaceHeader, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SurfaceHeader { | ||||||
|  |     pub tv_surface_header_renderer: SurfaceHeaderRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SurfaceHeaderRenderer { | ||||||
|  |     // TODO: really?
 | ||||||
|  |     // #[serde(default)]
 | ||||||
|  |     #[serde_as(as = "Text")] | ||||||
|  |     pub title: String, | ||||||
|  |     /// Channel avatar
 | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub thumbnail: Thumbnails, | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub banner: Thumbnails, | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub buttons: Vec<SubscribeButton>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SubscribeButton { | ||||||
|  |     pub subscribe_button_renderer: SubscribeButtonRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SubscribeButtonRenderer { | ||||||
|  |     pub channel_id: String, | ||||||
|  |     #[serde_as(as = "Option<Text>")] | ||||||
|  |     pub subscriber_count_text: Option<String>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Default, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SurfaceContent { | ||||||
|  |     pub section_list_renderer: SectionList, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Default, Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SectionList { | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub contents: Vec<Shelf>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Shelf { | ||||||
|  |     pub shelf_renderer: ShelfRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct ShelfRenderer { | ||||||
|  |     pub content: ShelfContent, | ||||||
|  |     pub endpoint: NavigationEndpoint, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct ShelfContent { | ||||||
|  |     pub horizontal_list_renderer: HorizontalListRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct HorizontalListRenderer { | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|  |     pub items: MapResult<Vec<Tile>>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Tile { | ||||||
|  |     pub tile_renderer: TileRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct TileRenderer { | ||||||
|  |     pub content_id: String, | ||||||
|  |     pub content_type: ContentType, | ||||||
|  |     pub header: TileHeader, | ||||||
|  |     pub metadata: Metadata, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct TileHeader { | ||||||
|  |     pub tile_header_renderer: TileHeaderRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct TileHeaderRenderer { | ||||||
|  |     pub thumbnail: Thumbnails, | ||||||
|  |     /// Contains Live tag
 | ||||||
|  |     #[serde(default)] | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub thumbnail_overlays: Vec<TimeOverlay>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Metadata { | ||||||
|  |     pub tile_metadata_renderer: MetadataRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct MetadataRenderer { | ||||||
|  |     #[serde_as(as = "Text")] | ||||||
|  |     pub title: String, | ||||||
|  |     #[serde(default)] | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub lines: Vec<Line>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Line { | ||||||
|  |     pub line_renderer: LineRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct LineRenderer { | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub items: Vec<LineItem>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct LineItem { | ||||||
|  |     pub line_item_renderer: LineItemRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct LineItemRenderer { | ||||||
|  |     #[serde_as(as = "Text")] | ||||||
|  |     pub text: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub(crate) enum ContentType { | ||||||
|  |     #[serde(rename = "TILE_CONTENT_TYPE_VIDEO")] | ||||||
|  |     Video, | ||||||
|  |     #[serde(rename = "TILE_CONTENT_TYPE_CHANNEL")] | ||||||
|  |     Channel, | ||||||
|  |     #[serde(rename = "TILE_CONTENT_TYPE_PLAYLIST")] | ||||||
|  |     Playlist, | ||||||
|  | } | ||||||
|  | @ -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>>, |  | ||||||
| } |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| pub(crate) mod channel; | pub(crate) mod channel; | ||||||
|  | pub(crate) mod channel_tv; | ||||||
| pub(crate) mod music_artist; | pub(crate) mod music_artist; | ||||||
| pub(crate) mod music_charts; | pub(crate) mod music_charts; | ||||||
| pub(crate) mod music_details; | pub(crate) mod music_details; | ||||||
|  | @ -16,7 +17,7 @@ pub(crate) mod video_details; | ||||||
| pub(crate) mod video_item; | pub(crate) mod video_item; | ||||||
| 
 | 
 | ||||||
| pub(crate) use channel::Channel; | pub(crate) use channel::Channel; | ||||||
| pub(crate) use channel::ChannelAbout; | pub(crate) use channel_tv::ChannelTv; | ||||||
| pub(crate) use music_artist::MusicArtist; | pub(crate) use music_artist::MusicArtist; | ||||||
| pub(crate) use music_artist::MusicArtistAlbums; | pub(crate) use music_artist::MusicArtistAlbums; | ||||||
| pub(crate) use music_charts::MusicCharts; | pub(crate) use music_charts::MusicCharts; | ||||||
|  | @ -30,11 +31,11 @@ pub(crate) use music_new::MusicNew; | ||||||
| pub(crate) use music_playlist::MusicPlaylist; | pub(crate) use music_playlist::MusicPlaylist; | ||||||
| pub(crate) use music_search::MusicSearch; | pub(crate) use music_search::MusicSearch; | ||||||
| pub(crate) use music_search::MusicSearchSuggestion; | pub(crate) use music_search::MusicSearchSuggestion; | ||||||
| pub(crate) use player::DrmLicense; |  | ||||||
| pub(crate) use player::Player; | pub(crate) use player::Player; | ||||||
| pub(crate) use playlist::Playlist; | pub(crate) use playlist::Playlist; | ||||||
|  | pub(crate) use playlist::PlaylistCont; | ||||||
| pub(crate) use search::Search; | pub(crate) use search::Search; | ||||||
| pub(crate) use search::SearchSuggestion; | pub(crate) use trends::Startpage; | ||||||
| pub(crate) use trends::Trending; | pub(crate) use trends::Trending; | ||||||
| pub(crate) use url_endpoint::ResolvedUrl; | pub(crate) use url_endpoint::ResolvedUrl; | ||||||
| pub(crate) use video_details::VideoComments; | pub(crate) use video_details::VideoComments; | ||||||
|  | @ -47,28 +48,12 @@ pub(crate) mod channel_rss; | ||||||
| #[cfg(feature = "rss")] | #[cfg(feature = "rss")] | ||||||
| pub(crate) use channel_rss::ChannelRss; | pub(crate) use channel_rss::ChannelRss; | ||||||
| 
 | 
 | ||||||
| #[cfg(feature = "userdata")] | use serde::Deserialize; | ||||||
| pub(crate) mod history; | use serde_with::{json::JsonString, serde_as, VecSkipError}; | ||||||
| #[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 crate::error::ExtractionError; | use crate::error::ExtractionError; | ||||||
| use crate::serializer::text::{AttributedText, Text, TextComponent}; | use crate::serializer::MapResult; | ||||||
| use crate::serializer::{MapResult, VecSkipErrorWrap}; | use crate::serializer::{text::Text, VecLogError}; | ||||||
| 
 | 
 | ||||||
| use self::video_item::YouTubeListRenderer; | use self::video_item::YouTubeListRenderer; | ||||||
| 
 | 
 | ||||||
|  | @ -78,18 +63,11 @@ pub(crate) struct ContentRenderer<T> { | ||||||
|     pub content: T, |     pub content: T, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Deserializes any object with an array field named `contents`, `tabs` or `items`.
 |  | ||||||
| ///
 |  | ||||||
| /// Invalid items are skipped
 |  | ||||||
| #[derive(Debug)] |  | ||||||
| pub(crate) struct ContentsRenderer<T> { |  | ||||||
|     pub contents: Vec<T>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| pub(crate) struct ContentsRendererLogged<T> { | #[serde(rename_all = "camelCase")] | ||||||
|     #[serde(alias = "items")] | pub(crate) struct ContentsRenderer<T> { | ||||||
|     pub contents: MapResult<Vec<T>>, |     #[serde(alias = "tabs")] | ||||||
|  |     pub contents: Vec<T>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -104,12 +82,6 @@ pub(crate) struct SectionList<T> { | ||||||
|     pub section_list_renderer: ContentsRenderer<T>, |     pub section_list_renderer: ContentsRenderer<T>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct TwoColumnBrowseResults<T> { |  | ||||||
|     pub two_column_browse_results_renderer: ContentsRenderer<T>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Debug, Deserialize)] | #[derive(Default, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ThumbnailsWrap { | pub(crate) struct ThumbnailsWrap { | ||||||
|  | @ -117,24 +89,12 @@ pub(crate) struct ThumbnailsWrap { | ||||||
|     pub thumbnail: Thumbnails, |     pub thumbnail: Thumbnails, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Default, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ImageView { |  | ||||||
|     pub image: Thumbnails, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AvatarViewModel { |  | ||||||
|     pub avatar_view_model: ImageView, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// List of images in different resolutions.
 | /// List of images in different resolutions.
 | ||||||
| /// Not only used for thumbnails, but also for avatars and banners.
 | /// Not only used for thumbnails, but also for avatars and banners.
 | ||||||
| #[derive(Default, Debug, Deserialize)] | #[derive(Default, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Thumbnails { | pub(crate) struct Thumbnails { | ||||||
|     #[serde(default, alias = "sources")] |     #[serde(default)] | ||||||
|     pub thumbnails: Vec<Thumbnail>, |     pub thumbnails: Vec<Thumbnail>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -152,16 +112,9 @@ pub(crate) struct ContinuationItemRenderer { | ||||||
|     pub continuation_endpoint: ContinuationEndpoint, |     pub continuation_endpoint: ContinuationEndpoint, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(untagged)] |  | ||||||
| pub(crate) enum ContinuationEndpoint { |  | ||||||
|     ContinuationCommand(ContinuationCommandWrap), |  | ||||||
|     CommandExecutorCommand(CommandExecutorCommandWrap), |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ContinuationCommandWrap { | pub(crate) struct ContinuationEndpoint { | ||||||
|     pub continuation_command: ContinuationCommand, |     pub continuation_command: ContinuationCommand, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -171,34 +124,7 @@ pub(crate) struct ContinuationCommand { | ||||||
|     pub token: String, |     pub token: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommandExecutorCommandWrap { |  | ||||||
|     pub command_executor_command: CommandExecutorCommand, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommandExecutorCommand { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     commands: Vec<ContinuationCommandWrap>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl ContinuationEndpoint { |  | ||||||
|     pub fn into_token(self) -> Option<String> { |  | ||||||
|         match self { |  | ||||||
|             Self::ContinuationCommand(cmd) => Some(cmd.continuation_command.token), |  | ||||||
|             Self::CommandExecutorCommand(cmd) => cmd |  | ||||||
|                 .command_executor_command |  | ||||||
|                 .commands |  | ||||||
|                 .into_iter() |  | ||||||
|                 .next() |  | ||||||
|                 .map(|c| c.continuation_command.token), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Icon { | pub(crate) struct Icon { | ||||||
|  | @ -238,92 +164,23 @@ pub(crate) enum ChannelBadgeStyle { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Alert { | pub(crate) struct Alert { | ||||||
|     pub alert_renderer: TextBox, |     pub alert_renderer: AlertRenderer, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct TextBox { | pub(crate) struct AlertRenderer { | ||||||
|     #[serde_as(as = "Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub text: String, |     pub text: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct SimpleHeaderRenderer { |  | ||||||
|     #[serde_as(as = "Text")] |  | ||||||
|     pub title: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct TextComponentBox { |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub text: TextComponent, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ResponseContext { | pub(crate) struct ResponseContext { | ||||||
|     pub visitor_data: Option<String>, |     pub visitor_data: Option<String>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AttachmentRun { |  | ||||||
|     pub element: AttachmentRunElement, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AttachmentRunElement { |  | ||||||
|     #[serde(rename = "type")] |  | ||||||
|     pub typ: AttachmentRunElementType, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AttachmentRunElementType { |  | ||||||
|     pub image_type: AttachmentRunElementImageType, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AttachmentRunElementImageType { |  | ||||||
|     pub image: AttachmentRunElementImage, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AttachmentRunElementImage { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub sources: Vec<AttachmentRunElementImageSource>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AttachmentRunElementImageSource { |  | ||||||
|     pub client_resource: ClientResource, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ClientResource { |  | ||||||
|     pub image_name: IconName, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum IconName { |  | ||||||
|     CheckCircleFilled, |  | ||||||
|     #[serde(alias = "AUDIO_BADGE")] |  | ||||||
|     MusicFilled, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // CONTINUATION
 | // CONTINUATION
 | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -331,14 +188,14 @@ pub enum IconName { | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Continuation { | pub(crate) struct Continuation { | ||||||
|     /// Number of search results
 |     /// Number of search results
 | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |     #[serde_as(as = "Option<JsonString>")] | ||||||
|     pub estimated_results: Option<u64>, |     pub estimated_results: Option<u64>, | ||||||
|     #[serde(
 |     #[serde(
 | ||||||
|         alias = "onResponseReceivedCommands", |         alias = "onResponseReceivedCommands", | ||||||
|         alias = "onResponseReceivedEndpoints" |         alias = "onResponseReceivedEndpoints" | ||||||
|     )] |     )] | ||||||
|     #[serde_as(as = "Option<VecSkipError<_>>")] |     #[serde_as(as = "Option<VecSkipError<_>>")] | ||||||
|     pub on_response_received_actions: Option<Vec<ContinuationActionWrap<YouTubeListItem>>>, |     pub on_response_received_actions: Option<Vec<ContinuationActionWrap>>, | ||||||
|     /// Used for channel video rich grid renderer
 |     /// Used for channel video rich grid renderer
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// A/B test seen on 19.10.2022
 |     /// A/B test seen on 19.10.2022
 | ||||||
|  | @ -347,15 +204,16 @@ pub(crate) struct Continuation { | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ContinuationActionWrap<T> { | pub(crate) struct ContinuationActionWrap { | ||||||
|     #[serde(alias = "reloadContinuationItemsCommand")] |     pub append_continuation_items_action: ContinuationAction, | ||||||
|     pub append_continuation_items_action: ContinuationAction<T>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ContinuationAction<T> { | pub(crate) struct ContinuationAction { | ||||||
|     pub continuation_items: MapResult<Vec<T>>, |     #[serde_as(as = "VecLogError<_>")] | ||||||
|  |     pub continuation_items: MapResult<Vec<YouTubeListItem>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -391,53 +249,9 @@ pub(crate) struct ErrorResponseContent { | ||||||
|     pub message: String, |     pub message: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // DESERIALIZER
 | /* | ||||||
| 
 | #MAPPING | ||||||
| impl<'de, T> Deserialize<'de> for ContentsRenderer<T> | */ | ||||||
| where |  | ||||||
|     T: Deserialize<'de>, |  | ||||||
| { |  | ||||||
|     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |  | ||||||
|     where |  | ||||||
|         D: serde::Deserializer<'de>, |  | ||||||
|     { |  | ||||||
|         struct ItemVisitor<T>(PhantomData<T>); |  | ||||||
| 
 |  | ||||||
|         impl<'de, T> Visitor<'de> for ItemVisitor<T> |  | ||||||
|         where |  | ||||||
|             T: Deserialize<'de>, |  | ||||||
|         { |  | ||||||
|             type Value = ContentsRenderer<T>; |  | ||||||
| 
 |  | ||||||
|             fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { |  | ||||||
|                 formatter.write_str("map") |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> |  | ||||||
|             where |  | ||||||
|                 A: serde::de::MapAccess<'de>, |  | ||||||
|             { |  | ||||||
|                 let mut contents = None; |  | ||||||
| 
 |  | ||||||
|                 while let Some(k) = map.next_key::<Cow<'de, str>>()? { |  | ||||||
|                     if k == "contents" || k == "tabs" || k == "items" { |  | ||||||
|                         contents = Some(ContentsRenderer { |  | ||||||
|                             contents: map.next_value::<VecSkipErrorWrap<T>>()?.0, |  | ||||||
|                         }); |  | ||||||
|                     } else { |  | ||||||
|                         map.next_value::<IgnoredAny>()?; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 contents.ok_or(serde::de::Error::missing_field("contents")) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         deserializer.deserialize_map(ItemVisitor(PhantomData::<T>)) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // MAPPING
 |  | ||||||
| 
 | 
 | ||||||
| impl From<Thumbnail> for crate::model::Thumbnail { | impl From<Thumbnail> for crate::model::Thumbnail { | ||||||
|     fn from(tn: Thumbnail) -> Self { |     fn from(tn: Thumbnail) -> Self { | ||||||
|  | @ -462,22 +276,9 @@ impl From<Thumbnails> for Vec<crate::model::Thumbnail> { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl ContentImage { |  | ||||||
|     pub(crate) fn into_image(self) -> ImageViewOl { |  | ||||||
|         match self { |  | ||||||
|             ContentImage::ThumbnailViewModel(image) => image, |  | ||||||
|             ContentImage::CollectionThumbnailViewModel { primary_thumbnail } => { |  | ||||||
|                 primary_thumbnail.thumbnail_view_model |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl From<Vec<ChannelBadge>> for crate::model::Verification { | impl From<Vec<ChannelBadge>> for crate::model::Verification { | ||||||
|     fn from(badges: Vec<ChannelBadge>) -> Self { |     fn from(badges: Vec<ChannelBadge>) -> Self { | ||||||
|         badges |         badges.get(0).map_or(crate::model::Verification::None, |b| { | ||||||
|             .first() |  | ||||||
|             .map_or(crate::model::Verification::None, |b| { |  | ||||||
|             match b.metadata_badge_renderer.style { |             match b.metadata_badge_renderer.style { | ||||||
|                 ChannelBadgeStyle::BadgeStyleTypeVerified => Self::Verified, |                 ChannelBadgeStyle::BadgeStyleTypeVerified => Self::Verified, | ||||||
|                 ChannelBadgeStyle::BadgeStyleTypeVerifiedArtist => Self::Artist, |                 ChannelBadgeStyle::BadgeStyleTypeVerifiedArtist => Self::Artist, | ||||||
|  | @ -491,240 +292,21 @@ impl From<Icon> for crate::model::Verification { | ||||||
|         match icon.icon_type { |         match icon.icon_type { | ||||||
|             IconType::Check => Self::Verified, |             IconType::Check => Self::Verified, | ||||||
|             IconType::OfficialArtistBadge => Self::Artist, |             IconType::OfficialArtistBadge => Self::Artist, | ||||||
|             IconType::Like => Self::None, |             _ => Self::None, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl From<AttachmentRun> for crate::model::Verification { | pub(crate) fn alerts_to_err(alerts: Option<Vec<Alert>>) -> ExtractionError { | ||||||
|     fn from(value: AttachmentRun) -> Self { |     match alerts { | ||||||
|         match value |         Some(alerts) => ExtractionError::ContentUnavailable( | ||||||
|             .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(), |  | ||||||
|         msg: alerts |  | ||||||
|             .map(|alerts| { |  | ||||||
|             alerts |             alerts | ||||||
|                 .into_iter() |                 .into_iter() | ||||||
|                 .map(|a| a.alert_renderer.text) |                 .map(|a| a.alert_renderer.text) | ||||||
|                 .collect::<Vec<_>>() |                 .collect::<Vec<_>>() | ||||||
|                 .join(" ") |                 .join(" ") | ||||||
|                     .into() |                 .into(), | ||||||
|             }) |         ), | ||||||
|             .unwrap_or_default(), |         None => ExtractionError::ContentUnavailable("content not found".into()), | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 |  | ||||||
| // 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(untagged)] |  | ||||||
| pub(crate) enum MetadataPart { |  | ||||||
|     Text { |  | ||||||
|         #[serde_as(as = "AttributedText")] |  | ||||||
|         text: TextComponent, |  | ||||||
|     }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     AvatarStack { avatar_stack: AvatarStackInner }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AvatarStackInner { |  | ||||||
|     pub avatar_stack_view_model: TextComponentBox, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl MetadataPart { |  | ||||||
|     pub fn into_text_component(self) -> TextComponent { |  | ||||||
|         match self { |  | ||||||
|             MetadataPart::Text { text } => text, |  | ||||||
|             MetadataPart::AvatarStack { avatar_stack } => avatar_stack.avatar_stack_view_model.text, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn as_str(&self) -> &str { |  | ||||||
|         match self { |  | ||||||
|             MetadataPart::Text { text } => text.as_str(), |  | ||||||
|             MetadataPart::AvatarStack { avatar_stack } => { |  | ||||||
|                 avatar_stack.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 {} |  | ||||||
|  |  | ||||||
|  | @ -5,8 +5,7 @@ use crate::serializer::text::Text; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     music_item::{ |     music_item::{ | ||||||
|         Button, Grid, ItemSection, MusicMicroformat, MusicThumbnailRenderer, SimpleHeader, |         Button, Grid, ItemSection, MusicThumbnailRenderer, SimpleHeader, SingleColumnBrowseResult, | ||||||
|         SingleColumnBrowseResult, |  | ||||||
|     }, |     }, | ||||||
|     SectionList, Tab, |     SectionList, Tab, | ||||||
| }; | }; | ||||||
|  | @ -15,10 +14,8 @@ use super::{ | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct MusicArtist { | pub(crate) struct MusicArtist { | ||||||
|     pub contents: Option<SingleColumnBrowseResult<Tab<SectionList<ItemSection>>>>, |     pub contents: SingleColumnBrowseResult<Tab<Option<SectionList<ItemSection>>>>, | ||||||
|     pub header: Option<Header>, |     pub header: Header, | ||||||
|     #[serde(default)] |  | ||||||
|     pub microformat: MusicMicroformat, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -76,12 +73,9 @@ pub(crate) struct ShareEntityEndpoint { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Response model for YouTube Music artist album page
 | /// Response model for YouTube Music artist album page
 | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct MusicArtistAlbums { | pub(crate) struct MusicArtistAlbums { | ||||||
|     #[serde(default)] |     pub header: SimpleHeader, | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub header: Option<SimpleHeader>, |  | ||||||
|     pub contents: SingleColumnBrowseResult<Tab<SectionList<Grid>>>, |     pub contents: SingleColumnBrowseResult<Tab<SectionList<Grid>>>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,13 +1,14 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{serde_as, DefaultOnError, VecSkipError}; | use serde_with::serde_as; | ||||||
|  | use serde_with::DefaultOnError; | ||||||
| 
 | 
 | ||||||
| use crate::serializer::text::Text; | use crate::serializer::text::Text; | ||||||
| 
 | 
 | ||||||
|  | use super::AlertRenderer; | ||||||
| use super::ContentsRenderer; | use super::ContentsRenderer; | ||||||
| use super::TextBox; |  | ||||||
| use super::{ | use super::{ | ||||||
|     music_item::{ItemSection, PlaylistPanelRenderer}, |     music_item::{ItemSection, PlaylistPanelRenderer}, | ||||||
|     ContentRenderer, |     ContentRenderer, SectionList, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /// Response model for YouTube Music track details
 | /// Response model for YouTube Music track details
 | ||||||
|  | @ -35,11 +36,9 @@ pub(crate) struct TabbedRenderer { | ||||||
|     pub watch_next_tabbed_results_renderer: TabbedRendererInner, |     pub watch_next_tabbed_results_renderer: TabbedRendererInner, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct TabbedRendererInner { | pub(crate) struct TabbedRendererInner { | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub tabs: Vec<Tab>, |     pub tabs: Vec<Tab>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -108,14 +107,14 @@ pub(crate) struct PlaylistPanel { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct MusicLyrics { | pub(crate) struct MusicLyrics { | ||||||
|     pub contents: ListOrMessage<LyricsSection>, |     pub contents: LyricsContents, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) enum ListOrMessage<T> { | pub(crate) struct LyricsContents { | ||||||
|     SectionListRenderer(ContentsRenderer<T>), |     pub message_renderer: Option<AlertRenderer>, | ||||||
|     MessageRenderer(TextBox), |     pub section_list_renderer: Option<ContentsRenderer<LyricsSection>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -137,14 +136,5 @@ pub(crate) struct LyricsRenderer { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct MusicRelated { | pub(crate) struct MusicRelated { | ||||||
|     pub contents: ListOrMessage<ItemSection>, |     pub contents: SectionList<ItemSection>, | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl<T> ListOrMessage<T> { |  | ||||||
|     pub fn into_res(self) -> Result<Vec<T>, String> { |  | ||||||
|         match self { |  | ||||||
|             ListOrMessage::SectionListRenderer(c) => Ok(c.contents), |  | ||||||
|             ListOrMessage::MessageRenderer(msg) => Err(msg.text), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,12 +1,12 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{rust::deserialize_ignore_any, serde_as}; | use serde_with::{rust::deserialize_ignore_any, serde_as}; | ||||||
| 
 | 
 | ||||||
| use crate::serializer::text::Text; | use crate::serializer::{text::Text, MapResult, VecLogError}; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     music_item::{ItemSection, SimpleHeader, SingleColumnBrowseResult}, |     music_item::{ItemSection, SimpleHeader, SingleColumnBrowseResult}, | ||||||
|     url_endpoint::BrowseEndpointWrap, |     url_endpoint::BrowseEndpointWrap, | ||||||
|     ContentsRendererLogged, SectionList, Tab, |     SectionList, Tab, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -18,7 +18,15 @@ pub(crate) struct MusicGenres { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Grid { | pub(crate) struct Grid { | ||||||
|     pub grid_renderer: ContentsRendererLogged<NavigationButton>, |     pub grid_renderer: GridRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct GridRenderer { | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|  |     pub items: MapResult<Vec<NavigationButton>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  |  | ||||||
|  | @ -1,8 +0,0 @@ | ||||||
| use serde::Deserialize; |  | ||||||
| 
 |  | ||||||
| use super::music_playlist::Contents; |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| pub(crate) struct MusicHistory { |  | ||||||
|     pub contents: Contents, |  | ||||||
| } |  | ||||||
|  | @ -1,45 +1,27 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{serde_as, DefaultOnError, VecSkipError}; | use serde_with::{serde_as, DefaultOnError, VecSkipError}; | ||||||
| 
 | 
 | ||||||
| use crate::serializer::text::{AttributedText, Text, TextComponents}; | use crate::serializer::text::{Text, TextComponents}; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     music_item::{ |     music_item::{ | ||||||
|         Button, ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicMicroformat, |         ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicThumbnailRenderer, | ||||||
|         MusicThumbnailRenderer, |         SingleColumnBrowseResult, | ||||||
|     }, |     }, | ||||||
|     url_endpoint::OnTapWrap, |     Tab, | ||||||
|     ContentsRenderer, SectionList, Tab, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /// Response model for YouTube Music playlists and albums
 | /// Response model for YouTube Music playlists and albums
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct MusicPlaylist { | pub(crate) struct MusicPlaylist { | ||||||
|     pub contents: Option<Contents>, |     pub contents: SingleColumnBrowseResult<Tab<SectionList>>, | ||||||
|     pub header: Option<Header>, |     pub header: Option<Header>, | ||||||
|     #[serde(default)] |  | ||||||
|     pub microformat: MusicMicroformat, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) enum Contents { |  | ||||||
|     SingleColumnBrowseResultsRenderer(ContentsRenderer<Tab<PlSectionList>>), |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     TwoColumnBrowseResultsRenderer { |  | ||||||
|         /// List content
 |  | ||||||
|         secondary_contents: PlSectionList, |  | ||||||
|         /// Header
 |  | ||||||
|         #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|         tabs: Vec<Tab<SectionList<Header>>>, |  | ||||||
|     }, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct PlSectionList { | pub(crate) struct SectionList { | ||||||
|     /// Includes a continuation token for fetching recommendations
 |     /// Includes a continuation token for fetching recommendations
 | ||||||
|     pub section_list_renderer: MusicContentsRenderer<ItemSection>, |     pub section_list_renderer: MusicContentsRenderer<ItemSection>, | ||||||
| } | } | ||||||
|  | @ -47,7 +29,6 @@ pub(crate) struct PlSectionList { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Header { | pub(crate) struct Header { | ||||||
|     #[serde(alias = "musicResponsiveHeaderRenderer")] |  | ||||||
|     pub music_detail_header_renderer: HeaderRenderer, |     pub music_detail_header_renderer: HeaderRenderer, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -67,48 +48,22 @@ pub(crate) struct HeaderRenderer { | ||||||
|     pub subtitle: TextComponents, |     pub subtitle: TextComponents, | ||||||
|     /// Playlist/album description. May contain hashtags which are
 |     /// Playlist/album description. May contain hashtags which are
 | ||||||
|     /// displayed as search links on the YouTube website.
 |     /// displayed as search links on the YouTube website.
 | ||||||
|     pub description: Option<Description>, |     #[serde_as(as = "Option<Text>")] | ||||||
|  |     pub description: Option<String>, | ||||||
|     /// Playlist thumbnail / album cover.
 |     /// Playlist thumbnail / album cover.
 | ||||||
|     /// Missing on artist_tracks view.
 |     /// Missing on artist_tracks view.
 | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub thumbnail: MusicThumbnailRenderer, |     pub thumbnail: MusicThumbnailRenderer, | ||||||
|     /// Channel (only on TwoColumnBrowseResultsRenderer)
 |  | ||||||
|     pub strapline_text_one: Option<TextComponents>, |  | ||||||
|     /// Number of tracks + playtime.
 |     /// Number of tracks + playtime.
 | ||||||
|     /// Missing on artist_tracks view.
 |     /// Missing on artist_tracks view.
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// `"64 songs", " • ", "3 hours, 40 minutes"`
 |     /// `"64 songs", " • ", "3 hours, 40 minutes"`
 | ||||||
|     ///
 |  | ||||||
|     /// `"1B views", " • ", "200 songs", " • ", "6+ hours"`
 |  | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub second_subtitle: Vec<String>, |     pub second_subtitle: Vec<String>, | ||||||
|     /// Channel (newer data model)
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub facepile: Option<AvatarStackViewModelWrap>, |  | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "DefaultOnError")] |     #[serde_as(as = "DefaultOnError")] | ||||||
|     pub menu: Option<HeaderMenu>, |     pub menu: Option<HeaderMenu>, | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub buttons: Vec<HeaderMenu>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(untagged)] |  | ||||||
| pub(crate) enum Description { |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     Shelf { |  | ||||||
|         music_description_shelf_renderer: DescriptionShelf, |  | ||||||
|     }, |  | ||||||
|     Text(TextComponents), |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct DescriptionShelf { |  | ||||||
|     pub description: TextComponents, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -123,41 +78,31 @@ pub(crate) struct HeaderMenu { | ||||||
| pub(crate) struct HeaderMenuRenderer { | pub(crate) struct HeaderMenuRenderer { | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|     pub top_level_buttons: Vec<Button>, |     pub top_level_buttons: Vec<TopLevelButton>, | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|     pub items: Vec<MusicItemMenuEntry>, |     pub items: Vec<MusicItemMenuEntry>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl From<Description> for TextComponents { | #[derive(Debug, Deserialize)] | ||||||
|     fn from(value: Description) -> Self { | #[serde(rename_all = "camelCase")] | ||||||
|         match value { | pub(crate) struct TopLevelButton { | ||||||
|             Description::Text(v) => v, |     pub button_renderer: ButtonRenderer, | ||||||
|             Description::Shelf { |  | ||||||
|                 music_description_shelf_renderer, |  | ||||||
|             } => music_description_shelf_renderer.description, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct AvatarStackViewModelWrap { | pub(crate) struct ButtonRenderer { | ||||||
|     pub avatar_stack_view_model: AvatarStackViewModel, |     pub navigation_endpoint: PlaylistEndpoint, | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct AvatarStackViewModel { |  | ||||||
|     // #[serde(default)]
 |  | ||||||
|     // pub avatars: Vec<AvatarViewModel>,
 |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub text: String, |  | ||||||
|     pub renderer_context: AvatarStackRendererContext, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct AvatarStackRendererContext { | pub(crate) struct PlaylistEndpoint { | ||||||
|     pub command_context: Option<OnTapWrap>, |     pub watch_playlist_endpoint: PlaylistWatchEndpoint, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct PlaylistWatchEndpoint { | ||||||
|  |     pub playlist_id: String, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,12 +2,11 @@ use std::ops::Range; | ||||||
| 
 | 
 | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::serde_as; | use serde_with::serde_as; | ||||||
| use serde_with::{DefaultOnError, DisplayFromStr, VecSkipError}; | use serde_with::{json::JsonString, DefaultOnError}; | ||||||
| 
 | 
 | ||||||
| use super::{Empty, ResponseContext, Thumbnails}; | use super::{ResponseContext, Thumbnails}; | ||||||
| use crate::serializer::{text::Text, MapResult}; | use crate::serializer::{text::Text, MapResult, VecLogError}; | ||||||
| 
 | 
 | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Player { | pub(crate) struct Player { | ||||||
|  | @ -15,14 +14,7 @@ pub(crate) struct Player { | ||||||
|     pub streaming_data: Option<StreamingData>, |     pub streaming_data: Option<StreamingData>, | ||||||
|     pub captions: Option<Captions>, |     pub captions: Option<Captions>, | ||||||
|     pub video_details: Option<VideoDetails>, |     pub video_details: Option<VideoDetails>, | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub storyboards: Option<Storyboards>, |  | ||||||
|     pub response_context: ResponseContext, |     pub response_context: ResponseContext, | ||||||
|     #[serde(default)] |  | ||||||
|     pub player_config: PlayerConfig, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub heartbeat_params: HeartbeatParams, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -37,15 +29,14 @@ pub(crate) enum PlayabilityStatus { | ||||||
|         #[serde(default)] |         #[serde(default)] | ||||||
|         reason: String, |         reason: String, | ||||||
|         #[serde(default)] |         #[serde(default)] | ||||||
|         error_screen: ErrorScreen, |         #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|  |         error_screen: Option<ErrorScreen>, | ||||||
|     }, |     }, | ||||||
|     /// Age limit / Private video
 |     /// Age limit / Private video
 | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde(rename_all = "camelCase")] | ||||||
|     LoginRequired { |     LoginRequired { | ||||||
|         #[serde(default)] |         #[serde(default)] | ||||||
|         reason: String, |         reason: String, | ||||||
|         #[serde(default)] |  | ||||||
|         messages: Vec<String>, |  | ||||||
|     }, |     }, | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde(rename_all = "camelCase")] | ||||||
|     LiveStreamOffline { |     LiveStreamOffline { | ||||||
|  | @ -60,18 +51,17 @@ pub(crate) enum PlayabilityStatus { | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[derive(Debug, Deserialize)] | ||||||
| #[derive(Default, Debug, Deserialize)] | pub(crate) struct Empty {} | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ErrorScreen { | pub(crate) struct ErrorScreen { | ||||||
|     #[serde(default)] |     pub player_error_message_renderer: ErrorMessage, | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub player_error_message_renderer: Option<ErrorMessage>, |  | ||||||
|     pub player_captcha_view_model: Option<Empty>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Default, Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ErrorMessage { | pub(crate) struct ErrorMessage { | ||||||
|     #[serde_as(as = "Text")] |     #[serde_as(as = "Text")] | ||||||
|  | @ -82,20 +72,18 @@ pub(crate) struct ErrorMessage { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct StreamingData { | pub(crate) struct StreamingData { | ||||||
|     #[serde_as(as = "DisplayFromStr")] |     #[serde_as(as = "JsonString")] | ||||||
|     pub expires_in_seconds: u32, |     pub expires_in_seconds: u32, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub formats: MapResult<Vec<Format>>, |     pub formats: MapResult<Vec<Format>>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub adaptive_formats: MapResult<Vec<Format>>, |     pub adaptive_formats: MapResult<Vec<Format>>, | ||||||
|     /// Only on livestreams
 |     /// Only on livestreams
 | ||||||
|     pub dash_manifest_url: Option<String>, |     pub dash_manifest_url: Option<String>, | ||||||
|     /// Only on livestreams
 |     /// Only on livestreams
 | ||||||
|     pub hls_manifest_url: Option<String>, |     pub hls_manifest_url: Option<String>, | ||||||
|     pub drm_params: Option<String>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "VecSkipError<_>")] |  | ||||||
|     pub initial_authorized_drm_track_types: Vec<DrmTrackType>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -114,7 +102,7 @@ pub(crate) struct Format { | ||||||
| 
 | 
 | ||||||
|     pub width: Option<u32>, |     pub width: Option<u32>, | ||||||
|     pub height: Option<u32>, |     pub height: Option<u32>, | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |     #[serde_as(as = "Option<JsonString>")] | ||||||
|     pub approx_duration_ms: Option<u32>, |     pub approx_duration_ms: Option<u32>, | ||||||
| 
 | 
 | ||||||
|     #[serde_as(as = "Option<crate::serializer::Range>")] |     #[serde_as(as = "Option<crate::serializer::Range>")] | ||||||
|  | @ -122,7 +110,7 @@ pub(crate) struct Format { | ||||||
|     #[serde_as(as = "Option<crate::serializer::Range>")] |     #[serde_as(as = "Option<crate::serializer::Range>")] | ||||||
|     pub init_range: Option<Range<u32>>, |     pub init_range: Option<Range<u32>>, | ||||||
| 
 | 
 | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |     #[serde_as(as = "Option<JsonString>")] | ||||||
|     pub content_length: Option<u64>, |     pub content_length: Option<u64>, | ||||||
| 
 | 
 | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|  | @ -137,23 +125,20 @@ pub(crate) struct Format { | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |     #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|     pub audio_quality: Option<AudioQuality>, |     pub audio_quality: Option<AudioQuality>, | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |     #[serde_as(as = "Option<JsonString>")] | ||||||
|     pub audio_sample_rate: Option<u32>, |     pub audio_sample_rate: Option<u32>, | ||||||
|     pub audio_channels: Option<u8>, |     pub audio_channels: Option<u8>, | ||||||
|     pub loudness_db: Option<f32>, |     pub loudness_db: Option<f32>, | ||||||
|     pub audio_track: Option<AudioTrack>, |     pub audio_track: Option<AudioTrack>, | ||||||
| 
 | 
 | ||||||
|     pub signature_cipher: Option<String>, |     pub signature_cipher: Option<String>, | ||||||
| 
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "VecSkipError<_>")] |  | ||||||
|     pub drm_families: Vec<DrmFamily>, |  | ||||||
|     pub drm_track_type: Option<DrmTrackType>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl Format { | impl Format { | ||||||
|     pub fn is_audio(&self) -> bool { |     pub fn is_audio(&self) -> bool { | ||||||
|         self.audio_quality.is_some() && self.audio_sample_rate.is_some() |         self.content_length.is_some() | ||||||
|  |             && self.audio_quality.is_some() | ||||||
|  |             && self.audio_sample_rate.is_some() | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn is_video(&self) -> bool { |     pub fn is_video(&self) -> bool { | ||||||
|  | @ -165,7 +150,7 @@ impl Format { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] | #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] | ||||||
| #[serde(rename_all = "lowercase")] | #[serde(rename_all = "lowercase")] | ||||||
| pub(crate) enum Quality { | pub(crate) enum Quality { | ||||||
|     Tiny, |     Tiny, | ||||||
|  | @ -179,19 +164,17 @@ pub(crate) enum Quality { | ||||||
|     Hd2160, |     Hd2160, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] | #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] | ||||||
| pub(crate) enum AudioQuality { | pub(crate) enum AudioQuality { | ||||||
|     #[serde(rename = "AUDIO_QUALITY_ULTRALOW")] |     #[serde(rename = "AUDIO_QUALITY_LOW", alias = "low")] | ||||||
|     UltraLow, |  | ||||||
|     #[serde(rename = "AUDIO_QUALITY_LOW")] |  | ||||||
|     Low, |     Low, | ||||||
|     #[serde(rename = "AUDIO_QUALITY_MEDIUM")] |     #[serde(rename = "AUDIO_QUALITY_MEDIUM", alias = "medium")] | ||||||
|     Medium, |     Medium, | ||||||
|     #[serde(rename = "AUDIO_QUALITY_HIGH")] |     #[serde(rename = "AUDIO_QUALITY_HIGH", alias = "high")] | ||||||
|     High, |     High, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)] | #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] | ||||||
| pub(crate) enum FormatType { | pub(crate) enum FormatType { | ||||||
|     #[default] |     #[default] | ||||||
|  | @ -206,7 +189,7 @@ pub(crate) struct ColorInfo { | ||||||
|     pub primaries: Primaries, |     pub primaries: Primaries, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)] | #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] | ||||||
| pub(crate) enum Primaries { | pub(crate) enum Primaries { | ||||||
|     #[default] |     #[default] | ||||||
|  | @ -214,24 +197,6 @@ pub(crate) enum Primaries { | ||||||
|     ColorPrimariesBt2020, |     ColorPrimariesBt2020, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| #[allow(clippy::enum_variant_names)] |  | ||||||
| pub(crate) enum DrmTrackType { |  | ||||||
|     DrmTrackTypeAudio, |  | ||||||
|     DrmTrackTypeSd, |  | ||||||
|     DrmTrackTypeHd, |  | ||||||
|     DrmTrackTypeUhd1, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub(crate) enum DrmFamily { |  | ||||||
|     Widevine, |  | ||||||
|     Playready, |  | ||||||
|     Fairplay, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Debug, Deserialize)] | #[derive(Default, Debug, Deserialize)] | ||||||
| #[serde(default, rename_all = "camelCase")] | #[serde(default, rename_all = "camelCase")] | ||||||
| pub(crate) struct AudioTrack { | pub(crate) struct AudioTrack { | ||||||
|  | @ -267,8 +232,8 @@ pub(crate) struct CaptionTrack { | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct VideoDetails { | pub(crate) struct VideoDetails { | ||||||
|     pub video_id: String, |     pub video_id: String, | ||||||
|     pub title: Option<String>, |     pub title: String, | ||||||
|     #[serde_as(as = "DisplayFromStr")] |     #[serde_as(as = "JsonString")] | ||||||
|     pub length_seconds: u32, |     pub length_seconds: u32, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub keywords: Vec<String>, |     pub keywords: Vec<String>, | ||||||
|  | @ -276,74 +241,8 @@ pub(crate) struct VideoDetails { | ||||||
|     pub short_description: Option<String>, |     pub short_description: Option<String>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub thumbnail: Thumbnails, |     pub thumbnail: Thumbnails, | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |     #[serde_as(as = "JsonString")] | ||||||
|     pub view_count: Option<u64>, |     pub view_count: u64, | ||||||
|     pub author: Option<String>, |     pub author: String, | ||||||
|     pub is_live_content: bool, |     pub is_live_content: bool, | ||||||
| } | } | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct Storyboards { |  | ||||||
|     pub player_storyboard_spec_renderer: StoryboardRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| 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,19 +1,22 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{serde_as, DefaultOnError, VecSkipError}; | use serde_with::{ | ||||||
|  |     json::JsonString, rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| use crate::serializer::text::{AttributedText, Text, TextComponent, TextComponents}; | use crate::serializer::text::{Text, TextComponent}; | ||||||
|  | use crate::serializer::{MapResult, VecLogError}; | ||||||
|  | use crate::util::MappingError; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     url_endpoint::OnTapWrap, video_item::YouTubeListRenderer, Alert, ContentRenderer, |     Alert, ContentsRenderer, ContinuationEndpoint, ResponseContext, SectionList, Tab, Thumbnails, | ||||||
|     ContentsRenderer, ImageView, PageHeaderRendererContent, PhMetadataView, ResponseContext, |     ThumbnailsWrap, | ||||||
|     SectionList, Tab, TextBox, ThumbnailsWrap, TwoColumnBrowseResults, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Playlist { | pub(crate) struct Playlist { | ||||||
|     pub contents: Option<TwoColumnBrowseResults<Tab<SectionList<ItemSection>>>>, |     pub contents: Option<Contents>, | ||||||
|     pub header: Option<Header>, |     pub header: Option<Header>, | ||||||
|     pub sidebar: Option<Sidebar>, |     pub sidebar: Option<Sidebar>, | ||||||
|     #[serde_as(as = "Option<DefaultOnError>")] |     #[serde_as(as = "Option<DefaultOnError>")] | ||||||
|  | @ -21,6 +24,21 @@ pub(crate) struct Playlist { | ||||||
|     pub response_context: ResponseContext, |     pub response_context: ResponseContext, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct PlaylistCont { | ||||||
|  |     #[serde(default)] | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub on_response_received_actions: Vec<OnResponseReceivedAction>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Contents { | ||||||
|  |     pub two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<ItemSection>>>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ItemSection { | pub(crate) struct ItemSection { | ||||||
|  | @ -30,15 +48,21 @@ pub(crate) struct ItemSection { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct PlaylistVideoListRenderer { | pub(crate) struct PlaylistVideoListRenderer { | ||||||
|     #[serde(alias = "richGridRenderer")] |     pub playlist_video_list_renderer: PlaylistVideoList, | ||||||
|     pub playlist_video_list_renderer: YouTubeListRenderer, | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct PlaylistVideoList { | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|  |     pub contents: MapResult<Vec<PlaylistItem>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) enum Header { | pub(crate) struct Header { | ||||||
|     PlaylistHeaderRenderer(HeaderRenderer), |     pub playlist_header_renderer: HeaderRenderer, | ||||||
|     PageHeaderRenderer(ContentRenderer<PageHeaderRendererContent<PageHeaderRendererInner>>), |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -70,13 +94,29 @@ pub(crate) struct PlaylistHeaderBanner { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Byline { | pub(crate) struct Byline { | ||||||
|     pub playlist_byline_renderer: TextBox, |     pub playlist_byline_renderer: BylineRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct BylineRenderer { | ||||||
|  |     #[serde_as(as = "Text")] | ||||||
|  |     pub text: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Sidebar { | pub(crate) struct Sidebar { | ||||||
|     pub playlist_sidebar_renderer: ContentsRenderer<SidebarItemPrimary>, |     pub playlist_sidebar_renderer: SidebarRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct SidebarRenderer { | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub items: Vec<SidebarItemPrimary>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -89,7 +129,6 @@ pub(crate) struct SidebarItemPrimary { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct SidebarPrimaryInfoRenderer { | pub(crate) struct SidebarPrimaryInfoRenderer { | ||||||
|     pub description: Option<TextComponents>, |  | ||||||
|     pub thumbnail_renderer: PlaylistThumbnailRenderer, |     pub thumbnail_renderer: PlaylistThumbnailRenderer, | ||||||
|     /// - `"495", " videos"`
 |     /// - `"495", " videos"`
 | ||||||
|     /// - `"3,310,996 views"`
 |     /// - `"3,310,996 views"`
 | ||||||
|  | @ -106,72 +145,64 @@ pub(crate) struct PlaylistThumbnailRenderer { | ||||||
|     pub playlist_video_thumbnail_renderer: ThumbnailsWrap, |     pub playlist_video_thumbnail_renderer: ThumbnailsWrap, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct PageHeaderRendererInner { | pub(crate) enum PlaylistItem { | ||||||
|     pub title: PhTitleView, |     /// Video in playlist
 | ||||||
|     pub metadata: PhMetadataView, |     PlaylistVideoRenderer(PlaylistVideoRenderer), | ||||||
|     pub actions: PhActions, |     /// Continauation items are located at the end of a list
 | ||||||
|     pub description: PhDescription, |     /// and contain the continuation token for progressive loading
 | ||||||
|     pub hero_image: PhHeroImage, |     #[serde(rename_all = "camelCase")] | ||||||
|  |     ContinuationItemRenderer { | ||||||
|  |         continuation_endpoint: ContinuationEndpoint, | ||||||
|  |     }, | ||||||
|  |     /// No video list item (e.g. ad) or unimplemented item
 | ||||||
|  |     #[serde(other, deserialize_with = "deserialize_ignore_any")] | ||||||
|  |     None, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /// Video displayed in a playlist
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct PlaylistVideoRenderer { | ||||||
|  |     pub video_id: String, | ||||||
|  |     pub thumbnail: Thumbnails, | ||||||
|  |     #[serde_as(as = "Text")] | ||||||
|  |     pub title: String, | ||||||
|  |     #[serde(rename = "shortBylineText")] | ||||||
|  |     pub channel: TextComponent, | ||||||
|  |     #[serde_as(as = "JsonString")] | ||||||
|  |     pub length_seconds: u32, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl TryFrom<PlaylistVideoRenderer> for crate::model::PlaylistVideo { | ||||||
|  |     type Error = MappingError; | ||||||
|  | 
 | ||||||
|  |     fn try_from(video: PlaylistVideoRenderer) -> Result<Self, Self::Error> { | ||||||
|  |         Ok(Self { | ||||||
|  |             id: video.video_id, | ||||||
|  |             name: video.title, | ||||||
|  |             length: video.length_seconds, | ||||||
|  |             thumbnail: video.thumbnail.into(), | ||||||
|  |             channel: crate::model::ChannelId::try_from(video.channel)?, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Continuation
 | ||||||
|  | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct PhDescription { | pub(crate) struct OnResponseReceivedAction { | ||||||
|     pub description_preview_view_model: PhDescription2, |     pub append_continuation_items_action: AppendAction, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct PhDescription2 { | pub(crate) struct AppendAction { | ||||||
|     #[serde_as(as = "Option<AttributedText>")] |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub description: Option<TextComponents>, |     pub continuation_items: MapResult<Vec<PlaylistItem>>, | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[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, |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,8 +1,5 @@ | ||||||
| use serde::{ | use serde::Deserialize; | ||||||
|     de::{IgnoredAny, Visitor}, | use serde_with::{json::JsonString, serde_as}; | ||||||
|     Deserialize, |  | ||||||
| }; |  | ||||||
| use serde_with::{serde_as, DisplayFromStr}; |  | ||||||
| 
 | 
 | ||||||
| use super::{video_item::YouTubeListRendererWrap, ResponseContext}; | use super::{video_item::YouTubeListRendererWrap, ResponseContext}; | ||||||
| 
 | 
 | ||||||
|  | @ -10,7 +7,7 @@ use super::{video_item::YouTubeListRendererWrap, ResponseContext}; | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Search { | pub(crate) struct Search { | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |     #[serde_as(as = "Option<JsonString>")] | ||||||
|     pub estimated_results: Option<u64>, |     pub estimated_results: Option<u64>, | ||||||
|     pub contents: Contents, |     pub contents: Contents, | ||||||
|     pub response_context: ResponseContext, |     pub response_context: ResponseContext, | ||||||
|  | @ -27,42 +24,3 @@ pub(crate) struct Contents { | ||||||
| pub(crate) struct TwoColumnSearchResultsRenderer { | pub(crate) struct TwoColumnSearchResultsRenderer { | ||||||
|     pub primary_contents: YouTubeListRendererWrap, |     pub primary_contents: YouTubeListRendererWrap, | ||||||
| } | } | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| pub(crate) struct SearchSuggestion(IgnoredAny, pub Vec<SearchSuggestionItem>, IgnoredAny); |  | ||||||
| 
 |  | ||||||
| #[derive(Debug)] |  | ||||||
| pub(crate) struct SearchSuggestionItem(pub String); |  | ||||||
| 
 |  | ||||||
| impl<'de> Deserialize<'de> for SearchSuggestionItem { |  | ||||||
|     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |  | ||||||
|     where |  | ||||||
|         D: serde::Deserializer<'de>, |  | ||||||
|     { |  | ||||||
|         struct ItemVisitor; |  | ||||||
| 
 |  | ||||||
|         impl<'de> Visitor<'de> for ItemVisitor { |  | ||||||
|             type Value = SearchSuggestionItem; |  | ||||||
| 
 |  | ||||||
|             fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { |  | ||||||
|                 formatter.write_str("search suggestion item") |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> |  | ||||||
|             where |  | ||||||
|                 A: serde::de::SeqAccess<'de>, |  | ||||||
|             { |  | ||||||
|                 match seq.next_element::<String>()? { |  | ||||||
|                     Some(s) => { |  | ||||||
|                         // Ignore the rest of the list
 |  | ||||||
|                         while seq.next_element::<IgnoredAny>()?.is_some() {} |  | ||||||
|                         Ok(SearchSuggestionItem(s)) |  | ||||||
|                     } |  | ||||||
|                     None => Err(serde::de::Error::invalid_length(0, &"1")), |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         deserializer.deserialize_seq(ItemVisitor) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,6 +1,14 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
|  | use serde_with::{serde_as, VecSkipError}; | ||||||
| 
 | 
 | ||||||
| use super::{video_item::YouTubeListRendererWrap, Tab, TwoColumnBrowseResults}; | use super::{video_item::YouTubeListRendererWrap, ResponseContext, Tab}; | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Startpage { | ||||||
|  |     pub contents: Contents, | ||||||
|  |     pub response_context: ResponseContext, | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -8,4 +16,16 @@ pub(crate) struct Trending { | ||||||
|     pub contents: Contents, |     pub contents: Contents, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type Contents = TwoColumnBrowseResults<Tab<YouTubeListRendererWrap>>; | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct Contents { | ||||||
|  |     pub two_column_browse_results_renderer: BrowseResults, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct BrowseResults { | ||||||
|  |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  |     pub tabs: Vec<Tab<YouTubeListRendererWrap>>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,12 +1,7 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{serde_as, DefaultOnError}; | use serde_with::{serde_as, DefaultOnError}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::model::UrlTarget; | ||||||
|     model::{TrackType, UrlTarget}, |  | ||||||
|     util, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| use super::Empty; |  | ||||||
| 
 | 
 | ||||||
| /// navigation/resolve_url response model
 | /// navigation/resolve_url response model
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -16,30 +11,21 @@ pub(crate) struct ResolvedUrl { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize, Default)] | ||||||
| #[serde(untagged)] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) enum NavigationEndpoint { | pub(crate) struct NavigationEndpoint { | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     Watch { |  | ||||||
|         #[serde(alias = "reelWatchEndpoint")] |  | ||||||
|         watch_endpoint: WatchEndpoint, |  | ||||||
|     }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     Browse { |  | ||||||
|         browse_endpoint: BrowseEndpoint, |  | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |     #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|         command_metadata: Option<CommandMetadata>, |     pub watch_endpoint: Option<WatchEndpoint>, | ||||||
|     }, |     #[serde(default)] | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|     Url { url_endpoint: UrlEndpoint }, |     pub browse_endpoint: Option<BrowseEndpoint>, | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde(default)] | ||||||
|     WatchPlaylist { |     #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|         watch_playlist_endpoint: WatchPlaylistEndpoint, |     pub url_endpoint: Option<UrlEndpoint>, | ||||||
|     }, |     #[serde(default)] | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|     #[allow(unused)] |     pub command_metadata: Option<CommandMetadata>, | ||||||
|     CreatePlaylist { create_playlist_endpoint: Empty }, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -66,12 +52,6 @@ pub(crate) struct BrowseEndpointWrap { | ||||||
|     pub browse_endpoint: BrowseEndpoint, |     pub browse_endpoint: BrowseEndpoint, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct WatchPlaylistEndpoint { |  | ||||||
|     pub playlist_id: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl<'de> Deserialize<'de> for BrowseEndpoint { | impl<'de> Deserialize<'de> for BrowseEndpoint { | ||||||
|     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | ||||||
|     where |     where | ||||||
|  | @ -89,7 +69,6 @@ impl<'de> Deserialize<'de> for BrowseEndpoint { | ||||||
|         let bep = BEp::deserialize(deserializer)?; |         let bep = BEp::deserialize(deserializer)?; | ||||||
| 
 | 
 | ||||||
|         // Remove the VL prefix from the playlist id
 |         // Remove the VL prefix from the playlist id
 | ||||||
|         #[allow(clippy::map_unwrap_or)] |  | ||||||
|         let browse_id = bep |         let browse_id = bep | ||||||
|             .browse_endpoint_context_supported_configs |             .browse_endpoint_context_supported_configs | ||||||
|             .as_ref() |             .as_ref() | ||||||
|  | @ -123,12 +102,9 @@ pub(crate) struct BrowseEndpointConfig { | ||||||
|     pub browse_endpoint_context_music_config: BrowseEndpointMusicConfig, |     pub browse_endpoint_context_music_config: BrowseEndpointMusicConfig, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct BrowseEndpointMusicConfig { | pub(crate) struct BrowseEndpointMusicConfig { | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub page_type: PageType, |     pub page_type: PageType, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -138,12 +114,9 @@ pub(crate) struct CommandMetadata { | ||||||
|     pub web_command_metadata: WebCommandMetadata, |     pub web_command_metadata: WebCommandMetadata, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct WebCommandMetadata { | pub(crate) struct WebCommandMetadata { | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub web_page_type: PageType, |     pub web_page_type: PageType, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -162,54 +135,16 @@ pub(crate) struct WatchEndpointConfig { | ||||||
|     pub music_video_type: MusicVideoType, |     pub music_video_type: MusicVideoType, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct OnTap { |  | ||||||
|     pub innertube_command: NavigationEndpoint, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct OnTapWrap { |  | ||||||
|     pub on_tap: OnTap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)] | #[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)] | ||||||
| pub(crate) enum MusicVideoType { | pub(crate) enum MusicVideoType { | ||||||
|     #[default] |     #[default] | ||||||
|     #[serde(rename = "MUSIC_VIDEO_TYPE_OMV", alias = "MUSIC_VIDEO_TYPE_UGC")] |     #[serde(rename = "MUSIC_VIDEO_TYPE_OMV")] | ||||||
|     Video, |     Video, | ||||||
|     #[serde(rename = "MUSIC_VIDEO_TYPE_ATV")] |     #[serde(rename = "MUSIC_VIDEO_TYPE_ATV")] | ||||||
|     Track, |     Track, | ||||||
|     #[serde(rename = "MUSIC_VIDEO_TYPE_PODCAST_EPISODE")] |  | ||||||
|     Episode, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl MusicVideoType { | #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] | ||||||
|     pub fn is_video(self) -> bool { |  | ||||||
|         self != Self::Track |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn from_is_video(is_video: bool) -> Self { |  | ||||||
|         if is_video { |  | ||||||
|             Self::Video |  | ||||||
|         } else { |  | ||||||
|             Self::Track |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl From<MusicVideoType> for TrackType { |  | ||||||
|     fn from(value: MusicVideoType) -> Self { |  | ||||||
|         match value { |  | ||||||
|             MusicVideoType::Video => Self::Video, |  | ||||||
|             MusicVideoType::Track => Self::Track, |  | ||||||
|             MusicVideoType::Episode => Self::Episode, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)] |  | ||||||
| pub(crate) enum PageType { | pub(crate) enum PageType { | ||||||
|     #[serde(
 |     #[serde(
 | ||||||
|         rename = "MUSIC_PAGE_TYPE_ARTIST", |         rename = "MUSIC_PAGE_TYPE_ARTIST", | ||||||
|  | @ -225,28 +160,15 @@ pub(crate) enum PageType { | ||||||
|     Channel, |     Channel, | ||||||
|     #[serde(rename = "MUSIC_PAGE_TYPE_PLAYLIST", alias = "WEB_PAGE_TYPE_PLAYLIST")] |     #[serde(rename = "MUSIC_PAGE_TYPE_PLAYLIST", alias = "WEB_PAGE_TYPE_PLAYLIST")] | ||||||
|     Playlist, |     Playlist, | ||||||
|     #[serde(rename = "MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE")] |  | ||||||
|     Podcast, |  | ||||||
|     #[serde(rename = "MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE")] |  | ||||||
|     Episode, |  | ||||||
|     #[default] |  | ||||||
|     Unknown, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl PageType { | impl PageType { | ||||||
|     pub(crate) fn to_url_target(self, id: String) -> Option<UrlTarget> { |     pub(crate) fn to_url_target(self, id: String) -> UrlTarget { | ||||||
|         match self { |         match self { | ||||||
|             PageType::Artist | PageType::Channel => Some(UrlTarget::Channel { id }), |             PageType::Artist => UrlTarget::Channel { id }, | ||||||
|             PageType::Album => Some(UrlTarget::Album { id }), |             PageType::Album => UrlTarget::Album { id }, | ||||||
|             PageType::Playlist => Some(UrlTarget::Playlist { id }), |             PageType::Channel => UrlTarget::Channel { id }, | ||||||
|             PageType::Podcast => Some(UrlTarget::Playlist { |             PageType::Playlist => UrlTarget::Playlist { id }, | ||||||
|                 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 +177,8 @@ impl PageType { | ||||||
| pub(crate) enum MusicPageType { | pub(crate) enum MusicPageType { | ||||||
|     Artist, |     Artist, | ||||||
|     Album, |     Album, | ||||||
|     Playlist { is_podcast: bool }, |     Playlist, | ||||||
|     Track { vtype: MusicVideoType }, |     Track { is_video: bool }, | ||||||
|     User, |  | ||||||
|     None, |     None, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -266,131 +187,45 @@ impl From<PageType> for MusicPageType { | ||||||
|         match t { |         match t { | ||||||
|             PageType::Artist => MusicPageType::Artist, |             PageType::Artist => MusicPageType::Artist, | ||||||
|             PageType::Album => MusicPageType::Album, |             PageType::Album => MusicPageType::Album, | ||||||
|             PageType::Playlist => MusicPageType::Playlist { is_podcast: false }, |             PageType::Playlist => MusicPageType::Playlist, | ||||||
|             PageType::Podcast => MusicPageType::Playlist { is_podcast: true }, |             PageType::Channel => MusicPageType::None, | ||||||
|             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(), |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl NavigationEndpoint { | impl NavigationEndpoint { | ||||||
|     /// Get the YouTube Music page and id from a browse/watch endpoint
 |     pub(crate) fn music_page(self) -> Option<(MusicPageType, String)> { | ||||||
|     pub(crate) fn music_page(self) -> Option<MusicPage> { |         self.browse_endpoint | ||||||
|         match self { |             .and_then(|be| { | ||||||
|             NavigationEndpoint::Watch { watch_endpoint } => { |                 be.browse_endpoint_context_supported_configs.map(|config| { | ||||||
|                 if watch_endpoint |                     ( | ||||||
|  |                         config.browse_endpoint_context_music_config.page_type.into(), | ||||||
|  |                         be.browse_id, | ||||||
|  |                     ) | ||||||
|  |                 }) | ||||||
|  |             }) | ||||||
|  |             .or_else(|| { | ||||||
|  |                 self.watch_endpoint.map(|watch| { | ||||||
|  |                     if watch | ||||||
|                         .playlist_id |                         .playlist_id | ||||||
|                         .map(|plid| plid.starts_with("RDQM")) |                         .map(|plid| plid.starts_with("RDQM")) | ||||||
|                         .unwrap_or_default() |                         .unwrap_or_default() | ||||||
|                     { |                     { | ||||||
|                         // Genre radios (e.g. "pop radio") will be skipped
 |                         // Genre radios (e.g. "pop radio") will be skipped
 | ||||||
|                     Some(MusicPage { |                         (MusicPageType::None, watch.video_id) | ||||||
|                         id: watch_endpoint.video_id, |  | ||||||
|                         typ: MusicPageType::None, |  | ||||||
|                     }) |  | ||||||
|                     } else { |                     } else { | ||||||
|                     Some(MusicPage { |                         ( | ||||||
|                         id: watch_endpoint.video_id, |                             MusicPageType::Track { | ||||||
|                         typ: MusicPageType::Track { |                                 is_video: watch | ||||||
|                             vtype: watch_endpoint |  | ||||||
|                                     .watch_endpoint_music_supported_configs |                                     .watch_endpoint_music_supported_configs | ||||||
|                                     .watch_endpoint_music_config |                                     .watch_endpoint_music_config | ||||||
|                                 .music_video_type, |                                     .music_video_type | ||||||
|  |                                     == MusicVideoType::Video, | ||||||
|                             }, |                             }, | ||||||
|                     }) |                             watch.video_id, | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             NavigationEndpoint::Browse { |  | ||||||
|                 browse_endpoint, .. |  | ||||||
|             } => browse_endpoint |  | ||||||
|                 .browse_endpoint_context_supported_configs |  | ||||||
|                 .map(|config| { |  | ||||||
|                     MusicPage::from_browse( |  | ||||||
|                         browse_endpoint.browse_id, |  | ||||||
|                         config.browse_endpoint_context_music_config.page_type, |  | ||||||
|                         ) |                         ) | ||||||
|                 }), |  | ||||||
|             NavigationEndpoint::Url { .. } => None, |  | ||||||
|             NavigationEndpoint::WatchPlaylist { |  | ||||||
|                 watch_playlist_endpoint, |  | ||||||
|             } => Some(MusicPage { |  | ||||||
|                 id: watch_playlist_endpoint.playlist_id, |  | ||||||
|                 typ: MusicPageType::Playlist { is_podcast: false }, |  | ||||||
|             }), |  | ||||||
|             NavigationEndpoint::CreatePlaylist { .. } => Some(MusicPage { |  | ||||||
|                 id: String::new(), |  | ||||||
|                 typ: MusicPageType::None, |  | ||||||
|             }), |  | ||||||
|                     } |                     } | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Get the page type of a browse endpoint
 |  | ||||||
|     pub(crate) fn page_type(&self) -> Option<PageType> { |  | ||||||
|         if let NavigationEndpoint::Browse { |  | ||||||
|             browse_endpoint, |  | ||||||
|             command_metadata, |  | ||||||
|         } = self |  | ||||||
|         { |  | ||||||
|             browse_endpoint |  | ||||||
|                 .browse_endpoint_context_supported_configs |  | ||||||
|                 .as_ref() |  | ||||||
|                 .map(|c| c.browse_endpoint_context_music_config.page_type) |  | ||||||
|                 .or_else(|| { |  | ||||||
|                     command_metadata |  | ||||||
|                         .as_ref() |  | ||||||
|                         .map(|c| c.web_command_metadata.web_page_type) |  | ||||||
|                 }) |                 }) | ||||||
|         } else { |             }) | ||||||
|             None |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub(crate) fn into_playlist_id(self) -> Option<String> { |  | ||||||
|         match self { |  | ||||||
|             NavigationEndpoint::Watch { watch_endpoint } => watch_endpoint.playlist_id, |  | ||||||
|             NavigationEndpoint::Browse { |  | ||||||
|                 browse_endpoint, |  | ||||||
|                 command_metadata, |  | ||||||
|             } => Some(browse_endpoint.browse_id).filter(|_| { |  | ||||||
|                 browse_endpoint |  | ||||||
|                     .browse_endpoint_context_supported_configs |  | ||||||
|                     .map(|c| c.browse_endpoint_context_music_config.page_type == PageType::Playlist) |  | ||||||
|                     .unwrap_or_default() |  | ||||||
|                     || command_metadata |  | ||||||
|                         .map(|c| c.web_command_metadata.web_page_type == PageType::Playlist) |  | ||||||
|                         .unwrap_or_default() |  | ||||||
|             }), |  | ||||||
|             NavigationEndpoint::Url { .. } => None, |  | ||||||
|             NavigationEndpoint::WatchPlaylist { |  | ||||||
|                 watch_playlist_endpoint, |  | ||||||
|             } => Some(watch_playlist_endpoint.playlist_id), |  | ||||||
|             NavigationEndpoint::CreatePlaylist { .. } => None, |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,25 +3,24 @@ | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError}; | use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError}; | ||||||
| 
 | 
 | ||||||
|  | use crate::serializer::text::TextComponent; | ||||||
| use crate::serializer::{ | use crate::serializer::{ | ||||||
|     text::{AccessibilityText, AttributedText, Text, TextComponent, TextComponents}, |     text::{AccessibilityText, AttributedText, Text, TextComponents}, | ||||||
|     MapResult, |     MapResult, VecLogError, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     url_endpoint::BrowseEndpointWrap, ContinuationEndpoint, ContinuationItemRenderer, Icon, |     url_endpoint::BrowseEndpointWrap, ContinuationEndpoint, ContinuationItemRenderer, Icon, | ||||||
|     MusicContinuationData, Thumbnails, |     MusicContinuationData, Thumbnails, | ||||||
| }; | }; | ||||||
| use super::{ | use super::{ChannelBadge, ResponseContext, YouTubeListItem}; | ||||||
|     ChannelBadge, ContentsRendererLogged, FrameworkUpdates, ImageView, ResponseContext, |  | ||||||
|     YouTubeListItem, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| /* | /* | ||||||
| #VIDEO DETAILS | #VIDEO DETAILS | ||||||
| */ | */ | ||||||
| 
 | 
 | ||||||
| /// Video details response
 | /// Video details response
 | ||||||
|  | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct VideoDetails { | pub(crate) struct VideoDetails { | ||||||
|  | @ -30,6 +29,7 @@ pub(crate) struct VideoDetails { | ||||||
|     /// Video ID
 |     /// Video ID
 | ||||||
|     pub current_video_endpoint: Option<CurrentVideoEndpoint>, |     pub current_video_endpoint: Option<CurrentVideoEndpoint>, | ||||||
|     /// Video chapters + comment section
 |     /// Video chapters + comment section
 | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub engagement_panels: MapResult<Vec<EngagementPanel>>, |     pub engagement_panels: MapResult<Vec<EngagementPanel>>, | ||||||
|     pub response_context: ResponseContext, |     pub response_context: ResponseContext, | ||||||
| } | } | ||||||
|  | @ -60,9 +60,11 @@ pub(crate) struct VideoResultsWrap { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Video metadata items
 | /// Video metadata items
 | ||||||
|  | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct VideoResults { | pub(crate) struct VideoResults { | ||||||
|  |     #[serde_as(as = "Option<VecLogError<_>>")] | ||||||
|     pub contents: Option<MapResult<Vec<VideoResultsItem>>>, |     pub contents: Option<MapResult<Vec<VideoResultsItem>>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -79,8 +81,8 @@ pub(crate) enum VideoResultsItem { | ||||||
|         /// Like/Dislike button
 |         /// Like/Dislike button
 | ||||||
|         video_actions: VideoActions, |         video_actions: VideoActions, | ||||||
|         /// Absolute textual date (e.g. `Dec 29, 2019`)
 |         /// Absolute textual date (e.g. `Dec 29, 2019`)
 | ||||||
|         #[serde_as(as = "Option<Text>")] |         #[serde_as(as = "Text")] | ||||||
|         date_text: Option<String>, |         date_text: String, | ||||||
|     }, |     }, | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde(rename_all = "camelCase")] | ||||||
|     VideoSecondaryInfoRenderer { |     VideoSecondaryInfoRenderer { | ||||||
|  | @ -149,46 +151,6 @@ pub(crate) enum TopLevelButton { | ||||||
|     SegmentedLikeDislikeButtonRenderer { |     SegmentedLikeDislikeButtonRenderer { | ||||||
|         like_button: ToggleButtonWrap, |         like_button: ToggleButtonWrap, | ||||||
|     }, |     }, | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     SegmentedLikeDislikeButtonViewModel { |  | ||||||
|         like_button_view_model: LikeButtonViewModelWrap, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct LikeButtonViewModelWrap { |  | ||||||
|     pub like_button_view_model: LikeButtonViewModel, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct LikeButtonViewModel { |  | ||||||
|     pub toggle_button_view_model: ToggleButtonViewModelWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ToggleButtonViewModelWrap { |  | ||||||
|     pub toggle_button_view_model: ToggleButtonViewModel, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ToggleButtonViewModel { |  | ||||||
|     pub default_button_view_model: ButtonViewModelWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ButtonViewModelWrap { |  | ||||||
|     pub button_view_model: ButtonViewModel, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ButtonViewModel { |  | ||||||
|     pub accessibility_text: String, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Like/Dislike button
 | /// Like/Dislike button
 | ||||||
|  | @ -341,6 +303,7 @@ pub(crate) struct RecommendationResultsWrap { | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct RecommendationResults { | pub(crate) struct RecommendationResults { | ||||||
|     /// Can be `None` for age-restricted videos
 |     /// Can be `None` for age-restricted videos
 | ||||||
|  |     #[serde_as(as = "Option<VecLogError<_>>")] | ||||||
|     pub results: Option<MapResult<Vec<YouTubeListItem>>>, |     pub results: Option<MapResult<Vec<YouTubeListItem>>>, | ||||||
|     #[serde_as(as = "Option<VecSkipError<_>>")] |     #[serde_as(as = "Option<VecSkipError<_>>")] | ||||||
|     pub continuations: Option<Vec<MusicContinuationData>>, |     pub continuations: Option<Vec<MusicContinuationData>>, | ||||||
|  | @ -378,7 +341,16 @@ pub(crate) enum EngagementPanelRenderer { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct ChapterMarkersContent { | pub(crate) struct ChapterMarkersContent { | ||||||
|     pub macro_markers_list_renderer: ContentsRendererLogged<MacroMarkersListItem>, |     pub macro_markers_list_renderer: MacroMarkersListRenderer, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Chapter markers
 | ||||||
|  | #[serde_as] | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | #[serde(rename_all = "camelCase")] | ||||||
|  | pub(crate) struct MacroMarkersListRenderer { | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|  |     pub contents: MapResult<Vec<MacroMarkersListItem>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Chapter marker
 | /// Chapter marker
 | ||||||
|  | @ -464,6 +436,7 @@ pub(crate) struct CommentItemSectionHeaderMenuItem { | ||||||
| */ | */ | ||||||
| 
 | 
 | ||||||
| /// Video comments continuation response
 | /// Video comments continuation response
 | ||||||
|  | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct VideoComments { | pub(crate) struct VideoComments { | ||||||
|  | @ -477,8 +450,8 @@ pub(crate) struct VideoComments { | ||||||
|     /// - Comment replies: appendContinuationItemsAction
 |     /// - Comment replies: appendContinuationItemsAction
 | ||||||
|     ///   - n*commentRenderer, continuationItemRenderer:
 |     ///   - n*commentRenderer, continuationItemRenderer:
 | ||||||
|     ///     replies + continuation
 |     ///     replies + continuation
 | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>, |     pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>, | ||||||
|     pub framework_updates: Option<FrameworkUpdates<Payload>>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Video comments continuation
 | /// Video comments continuation
 | ||||||
|  | @ -490,9 +463,11 @@ pub(crate) struct CommentsContItem { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Video comments continuation action
 | /// Video comments continuation action
 | ||||||
|  | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct AppendComments { | pub(crate) struct AppendComments { | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub continuation_items: MapResult<Vec<CommentListItem>>, |     pub continuation_items: MapResult<Vec<CommentListItem>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -501,13 +476,23 @@ pub(crate) struct AppendComments { | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) enum CommentListItem { | pub(crate) enum CommentListItem { | ||||||
|     /// Top-level comment
 |     /// Top-level comment
 | ||||||
|     CommentThreadRenderer(CommentThreadRenderer), |     #[serde(rename_all = "camelCase")] | ||||||
|  |     CommentThreadRenderer { | ||||||
|  |         comment: Comment, | ||||||
|  |         /// Continuation token to fetch replies
 | ||||||
|  |         #[serde(default)] | ||||||
|  |         replies: Replies, | ||||||
|  |         #[serde(default)] | ||||||
|  |         #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|  |         rendering_priority: CommentPriority, | ||||||
|  |     }, | ||||||
|     /// Reply comment
 |     /// Reply comment
 | ||||||
|     CommentRenderer(CommentRenderer), |     CommentRenderer(CommentRenderer), | ||||||
|     /// Reply comment (A/B #14)
 |  | ||||||
|     CommentViewModel(CommentViewModel), |  | ||||||
|     /// Continuation token to fetch more comments
 |     /// Continuation token to fetch more comments
 | ||||||
|     ContinuationItemRenderer(ContinuationItemVariants), |     #[serde(rename_all = "camelCase")] | ||||||
|  |     ContinuationItemRenderer { | ||||||
|  |         continuation_endpoint: ContinuationEndpoint, | ||||||
|  |     }, | ||||||
|     /// Header of the comment section (contains number of comments)
 |     /// Header of the comment section (contains number of comments)
 | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde(rename_all = "camelCase")] | ||||||
|     CommentsHeaderRenderer { |     CommentsHeaderRenderer { | ||||||
|  | @ -517,45 +502,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 into_token(self) -> Option<String> { |  | ||||||
|         match self { |  | ||||||
|             ContinuationItemVariants::Ep { |  | ||||||
|                 continuation_endpoint, |  | ||||||
|             } => continuation_endpoint, |  | ||||||
|             ContinuationItemVariants::Btn { button } => button.button_renderer.command, |  | ||||||
|         } |  | ||||||
|         .into_token() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentThreadRenderer { |  | ||||||
|     /// Missing on the FrameworkUpdate data model (A/B #14)
 |  | ||||||
|     pub comment: Option<Comment>, |  | ||||||
|     pub comment_view_model: Option<CommentViewModelWrap>, |  | ||||||
|     /// Continuation token to fetch replies
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub replies: Replies, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub rendering_priority: CommentPriority, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct Comment { | pub(crate) struct Comment { | ||||||
|  | @ -590,13 +536,11 @@ pub(crate) struct CommentRenderer { | ||||||
|     pub author_comment_badge: Option<AuthorCommentBadge>, |     pub author_comment_badge: Option<AuthorCommentBadge>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub reply_count: u64, |     pub reply_count: u64, | ||||||
|     #[serde_as(as = "Option<Text>")] |  | ||||||
|     pub vote_count: Option<String>, |  | ||||||
|     /// Buttons for comment interaction (Like/Dislike/Reply)
 |     /// Buttons for comment interaction (Like/Dislike/Reply)
 | ||||||
|     pub action_buttons: CommentActionButtons, |     pub action_buttons: CommentActionButtons, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Default, Clone, Copy, Debug, Deserialize)] | #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)] | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] | ||||||
| pub(crate) enum CommentPriority { | pub(crate) enum CommentPriority { | ||||||
|     /// Default rendering priority
 |     /// Default rendering priority
 | ||||||
|  | @ -606,27 +550,6 @@ pub(crate) enum CommentPriority { | ||||||
|     RenderingPriorityPinnedComment, |     RenderingPriorityPinnedComment, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl From<CommentPriority> for bool { |  | ||||||
|     fn from(value: CommentPriority) -> Self { |  | ||||||
|         matches!(value, CommentPriority::RenderingPriorityPinnedComment) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentViewModelWrap { |  | ||||||
|     pub comment_view_model: CommentViewModel, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentViewModel { |  | ||||||
|     pub comment_id: String, |  | ||||||
|     pub comment_key: String, |  | ||||||
|     pub comment_surface_key: String, |  | ||||||
|     pub toolbar_state_key: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Does not contain replies directly but a continuation token
 | /// Does not contain replies directly but a continuation token
 | ||||||
| /// for fetching them.
 | /// for fetching them.
 | ||||||
| #[derive(Default, Debug, Deserialize)] | #[derive(Default, Debug, Deserialize)] | ||||||
|  | @ -658,6 +581,7 @@ pub(crate) struct CommentActionButtons { | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct CommentActionButtonsRenderer { | pub(crate) struct CommentActionButtonsRenderer { | ||||||
|  |     pub like_button: ToggleButtonWrap, | ||||||
|     pub creator_heart: Option<CreatorHeart>, |     pub creator_heart: Option<CreatorHeart>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -690,107 +614,3 @@ pub(crate) struct AuthorCommentBadgeRenderer { | ||||||
|     /// Artist: `OFFICIAL_ARTIST_BADGE`
 |     /// Artist: `OFFICIAL_ARTIST_BADGE`
 | ||||||
|     pub icon: Icon, |     pub icon: Icon, | ||||||
| } | } | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) enum Payload { |  | ||||||
|     CommentEntityPayload(CommentEntityPayload), |  | ||||||
|     CommentSurfaceEntityPayload(CommentSurfaceEntityPayload), |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     EngagementToolbarStateEntityPayload { |  | ||||||
|         heart_state: HeartState, |  | ||||||
|     }, |  | ||||||
|     #[serde(other, deserialize_with = "deserialize_ignore_any")] |  | ||||||
|     None, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentEntityPayload { |  | ||||||
|     pub properties: CommentProperties, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub author: Option<CommentAuthor>, |  | ||||||
|     pub toolbar: CommentToolbar, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub avatar: ImageView, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentSurfaceEntityPayload { |  | ||||||
|     pub voice_reply_container_view_model: Option<VoiceReplyContainer>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentProperties { |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub content: TextComponents, |  | ||||||
|     pub published_time: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentAuthor { |  | ||||||
|     pub channel_id: String, |  | ||||||
|     pub display_name: String, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub is_verified: bool, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub is_artist: bool, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub is_creator: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct CommentToolbar { |  | ||||||
|     pub like_count_notliked: String, |  | ||||||
|     pub reply_count: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Copy, Clone, Deserialize)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub(crate) enum HeartState { |  | ||||||
|     ToolbarHeartStateUnhearted, |  | ||||||
|     ToolbarHeartStateHearted, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl From<HeartState> for bool { |  | ||||||
|     fn from(value: HeartState) -> Self { |  | ||||||
|         match value { |  | ||||||
|             HeartState::ToolbarHeartStateUnhearted => false, |  | ||||||
|             HeartState::ToolbarHeartStateHearted => true, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ContinuationButton { |  | ||||||
|     pub button_renderer: ContinuationButtonRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ContinuationButtonRenderer { |  | ||||||
|     pub command: ContinuationEndpoint, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct VoiceReplyContainer { |  | ||||||
|     pub voice_reply_container_view_model: VoiceReplyContainer2, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct VoiceReplyContainer2 { |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub transcript_text: TextComponents, |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,25 +1,26 @@ | ||||||
|  | use once_cell::sync::Lazy; | ||||||
|  | use regex::Regex; | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{ | use serde_with::{ | ||||||
|     rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError, |     json::JsonString, rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError, | ||||||
| }; | }; | ||||||
| use time::OffsetDateTime; | use time::{Duration, OffsetDateTime}; | ||||||
| 
 | 
 | ||||||
| use super::{ChannelBadge, ContentImage, ContinuationItemRenderer, PhMetadataView, Thumbnails}; | use super::{url_endpoint::NavigationEndpoint, ChannelBadge, ContinuationEndpoint, Thumbnails}; | ||||||
| use crate::{ | use crate::{ | ||||||
|     model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem}, |     model::{ | ||||||
|  |         Channel, ChannelId, ChannelInfo, ChannelItem, ChannelTag, PlaylistItem, VideoItem, | ||||||
|  |         YouTubeItem, | ||||||
|  |     }, | ||||||
|     param::Language, |     param::Language, | ||||||
|     serializer::{ |     serializer::{ | ||||||
|         text::{AttributedText, Text, TextComponent}, |         text::{AccessibilityText, Text, TextComponent}, | ||||||
|         MapResult, |         MapResult, VecLogError, | ||||||
|     }, |     }, | ||||||
|     util::{self, timeago, TryRemove}, |     timeago, | ||||||
|  |     util::{self, TryRemove}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| #[cfg(feature = "userdata")] |  | ||||||
| use crate::{client::response::SimpleHeaderRenderer, model::HistoryItem}; |  | ||||||
| #[cfg(feature = "userdata")] |  | ||||||
| use time::UtcOffset; |  | ||||||
| 
 |  | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -27,19 +28,18 @@ pub(crate) enum YouTubeListItem { | ||||||
|     #[serde(alias = "gridVideoRenderer", alias = "compactVideoRenderer")] |     #[serde(alias = "gridVideoRenderer", alias = "compactVideoRenderer")] | ||||||
|     VideoRenderer(VideoRenderer), |     VideoRenderer(VideoRenderer), | ||||||
|     ReelItemRenderer(ReelItemRenderer), |     ReelItemRenderer(ReelItemRenderer), | ||||||
|     ShortsLockupViewModel(ShortsLockupViewModel), |  | ||||||
|     PlaylistVideoRenderer(PlaylistVideoRenderer), |  | ||||||
| 
 | 
 | ||||||
|     #[serde(alias = "gridPlaylistRenderer")] |     #[serde(alias = "gridPlaylistRenderer")] | ||||||
|     PlaylistRenderer(PlaylistRenderer), |     PlaylistRenderer(PlaylistRenderer), | ||||||
| 
 | 
 | ||||||
|     ChannelRenderer(ChannelRenderer), |     ChannelRenderer(ChannelRenderer), | ||||||
| 
 | 
 | ||||||
|     LockupViewModel(LockupViewModel), |     /// Continauation items are located at the end of a list
 | ||||||
| 
 |  | ||||||
|     /// Continuation items are located at the end of a list
 |  | ||||||
|     /// and contain the continuation token for progressive loading
 |     /// and contain the continuation token for progressive loading
 | ||||||
|     ContinuationItemRenderer(ContinuationItemRenderer), |     #[serde(rename_all = "camelCase")] | ||||||
|  |     ContinuationItemRenderer { | ||||||
|  |         continuation_endpoint: ContinuationEndpoint, | ||||||
|  |     }, | ||||||
| 
 | 
 | ||||||
|     /// Corrected search query
 |     /// Corrected search query
 | ||||||
|     #[serde(rename_all = "camelCase")] |     #[serde(rename_all = "camelCase")] | ||||||
|  | @ -48,6 +48,9 @@ pub(crate) enum YouTubeListItem { | ||||||
|         corrected_query: String, |         corrected_query: String, | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|  |     /// Channel metadata (about tab)
 | ||||||
|  |     ChannelAboutFullMetadataRenderer(ChannelFullMetadata), | ||||||
|  | 
 | ||||||
|     /// Contains video on startpage
 |     /// Contains video on startpage
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// Seems to be currently A/B tested on the channel page,
 |     /// Seems to be currently A/B tested on the channel page,
 | ||||||
|  | @ -65,20 +68,11 @@ pub(crate) enum YouTubeListItem { | ||||||
|     /// GridRenderer: contains videos on channel page
 |     /// GridRenderer: contains videos on channel page
 | ||||||
|     #[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")] |     #[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")] | ||||||
|     ItemSectionRenderer { |     ItemSectionRenderer { | ||||||
|         #[cfg(feature = "userdata")] |  | ||||||
|         header: Option<ItemSectionHeader>, |  | ||||||
|         #[serde(alias = "items")] |         #[serde(alias = "items")] | ||||||
|  |         #[serde_as(as = "VecLogError<_>")] | ||||||
|         contents: MapResult<Vec<YouTubeListItem>>, |         contents: MapResult<Vec<YouTubeListItem>>, | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     /// Age-restricted channel
 |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     ChannelAgeGateRenderer { |  | ||||||
|         channel_title: String, |  | ||||||
|         #[serde_as(as = "Text")] |  | ||||||
|         main_text: String, |  | ||||||
|     }, |  | ||||||
| 
 |  | ||||||
|     /// No video list item (e.g. ad) or unimplemented item
 |     /// No video list item (e.g. ad) or unimplemented item
 | ||||||
|     ///
 |     ///
 | ||||||
|     /// Unimplemented:
 |     /// Unimplemented:
 | ||||||
|  | @ -141,98 +135,18 @@ pub(crate) struct ReelItemRenderer { | ||||||
|     /// Contains `No views` if the view count is zero
 |     /// Contains `No views` if the view count is zero
 | ||||||
|     #[serde_as(as = "Option<Text>")] |     #[serde_as(as = "Option<Text>")] | ||||||
|     pub view_count_text: Option<String>, |     pub view_count_text: Option<String>, | ||||||
|  |     /// video duration
 | ||||||
|  |     ///
 | ||||||
|  |     /// Example: `the horror maze - 44 seconds - play video`
 | ||||||
|  |     ///
 | ||||||
|  |     /// Dashes may be `\u2013` (emdash)
 | ||||||
|  |     #[serde_as(as = "Option<AccessibilityText>")] | ||||||
|  |     pub accessibility: Option<String>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "DefaultOnError")] |     #[serde_as(as = "DefaultOnError")] | ||||||
|     pub navigation_endpoint: Option<ReelNavigationEndpoint>, |     pub navigation_endpoint: Option<ReelNavigationEndpoint>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // New short video item
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ShortsLockupViewModel { |  | ||||||
|     /// `shorts-shelf-item-[video_id]`
 |  | ||||||
|     pub entity_id: String, |  | ||||||
|     pub thumbnail: Thumbnails, |  | ||||||
|     pub overlay_metadata: ShortsOverlayMetadata, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ShortsOverlayMetadata { |  | ||||||
|     /// Title
 |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub primary_text: String, |  | ||||||
|     /// View count
 |  | ||||||
|     #[serde_as(as = "Option<AttributedText>")] |  | ||||||
|     pub secondary_text: Option<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Generalized list item, currently only used for channel playlists and YTM items
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct LockupViewModel { |  | ||||||
|     pub content_id: String, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub content_type: LockupContentType, |  | ||||||
|     pub content_image: ContentImage, |  | ||||||
|     pub metadata: LockupViewModelMetadata, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| #[allow(clippy::enum_variant_names)] |  | ||||||
| pub(crate) enum LockupContentType { |  | ||||||
|     LockupContentTypePlaylist, |  | ||||||
|     LockupContentTypeVideo, |  | ||||||
|     #[default] |  | ||||||
|     Unknown, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct LockupViewModelMetadata { |  | ||||||
|     pub lockup_metadata_view_model: LockupViewModelMetadataInner, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct LockupViewModelMetadataInner { |  | ||||||
|     #[serde_as(as = "AttributedText")] |  | ||||||
|     pub title: String, |  | ||||||
|     pub metadata: PhMetadataView, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Video displayed in a playlist
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct PlaylistVideoRenderer { |  | ||||||
|     pub video_id: String, |  | ||||||
|     pub thumbnail: Thumbnails, |  | ||||||
|     #[serde_as(as = "Text")] |  | ||||||
|     pub title: String, |  | ||||||
|     #[serde(rename = "shortBylineText")] |  | ||||||
|     pub channel: TextComponent, |  | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |  | ||||||
|     pub length_seconds: Option<u32>, |  | ||||||
|     /// Regular video: `["29K views", " • ", "13 years ago"]`
 |  | ||||||
|     /// Livestream: `["66K", " watching"]`
 |  | ||||||
|     /// Upcoming: `["8", " waiting"]`
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError<Text>")] |  | ||||||
|     pub video_info: Vec<String>, |  | ||||||
|     /// Contains Short/Live tag
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub thumbnail_overlays: Vec<TimeOverlay>, |  | ||||||
|     /// Release date for upcoming videos
 |  | ||||||
|     pub upcoming_event_data: Option<UpcomingEventData>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Playlist displayed in search results
 | /// Playlist displayed in search results
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|  | @ -247,7 +161,7 @@ pub(crate) struct PlaylistRenderer { | ||||||
|     /// The first item of this list contains the playlist thumbnail,
 |     /// The first item of this list contains the playlist thumbnail,
 | ||||||
|     /// subsequent items contain very small thumbnails of the next playlist videos
 |     /// subsequent items contain very small thumbnails of the next playlist videos
 | ||||||
|     pub thumbnails: Option<Vec<Thumbnails>>, |     pub thumbnails: Option<Vec<Thumbnails>>, | ||||||
|     #[serde_as(as = "Option<DisplayFromStr>")] |     #[serde_as(as = "Option<JsonString>")] | ||||||
|     pub video_count: Option<u64>, |     pub video_count: Option<u64>, | ||||||
|     #[serde_as(as = "Option<Text>")] |     #[serde_as(as = "Option<Text>")] | ||||||
|     pub video_count_short_text: Option<String>, |     pub video_count_short_text: Option<String>, | ||||||
|  | @ -292,25 +206,20 @@ pub(crate) struct YouTubeListRendererWrap { | ||||||
|     pub section_list_renderer: YouTubeListRenderer, |     pub section_list_renderer: YouTubeListRenderer, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct YouTubeListRenderer { | pub(crate) struct YouTubeListRenderer { | ||||||
|  |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub contents: MapResult<Vec<YouTubeListItem>>, |     pub contents: MapResult<Vec<YouTubeListItem>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[cfg(feature = "userdata")] |  | ||||||
| #[derive(Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub(crate) struct ItemSectionHeader { |  | ||||||
|     pub item_section_header_renderer: SimpleHeaderRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub(crate) struct UpcomingEventData { | pub(crate) struct UpcomingEventData { | ||||||
|     /// Unixtime in seconds
 |     /// Unixtime in seconds
 | ||||||
|     #[serde_as(as = "DisplayFromStr")] |     #[serde_as(as = "JsonString")] | ||||||
|     pub start_time: i64, |     pub start_time: i64, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -364,6 +273,7 @@ pub(crate) enum TimeOverlayStyle { | ||||||
|     Default, |     Default, | ||||||
|     Live, |     Live, | ||||||
|     Shorts, |     Shorts, | ||||||
|  |     Upcoming, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -425,14 +335,40 @@ pub(crate) struct ReelPlayerHeaderRenderer { | ||||||
|     pub timestamp_text: String, |     pub timestamp_text: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| trait IsLive { | #[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, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub(crate) trait IsLive { | ||||||
|     fn is_live(&self) -> bool; |     fn is_live(&self) -> bool; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| trait IsShort { | pub(crate) trait IsShort { | ||||||
|     fn is_short(&self) -> bool; |     fn is_short(&self) -> bool; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | pub(crate) trait IsUpcoming { | ||||||
|  |     fn is_upcoming(&self) -> bool; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| impl IsLive for Vec<VideoBadge> { | impl IsLive for Vec<VideoBadge> { | ||||||
|     fn is_live(&self) -> bool { |     fn is_live(&self) -> bool { | ||||||
|         self.iter().any(|badge| { |         self.iter().any(|badge| { | ||||||
|  | @ -457,6 +393,14 @@ impl IsShort for Vec<TimeOverlay> { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl IsUpcoming for Vec<TimeOverlay> { | ||||||
|  |     fn is_upcoming(&self) -> bool { | ||||||
|  |         self.iter().any(|overlay| { | ||||||
|  |             overlay.thumbnail_overlay_time_status_renderer.style == TimeOverlayStyle::Upcoming | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /// Result of mapping a list of different YouTube enities
 | /// Result of mapping a list of different YouTube enities
 | ||||||
| /// (videos, channels, playlists)
 | /// (videos, channels, playlists)
 | ||||||
| #[derive(Debug)] | #[derive(Debug)] | ||||||
|  | @ -468,6 +412,7 @@ pub(crate) struct YouTubeListMapper<T> { | ||||||
|     pub warnings: Vec<String>, |     pub warnings: Vec<String>, | ||||||
|     pub ctoken: Option<String>, |     pub ctoken: Option<String>, | ||||||
|     pub corrected_query: Option<String>, |     pub corrected_query: Option<String>, | ||||||
|  |     pub channel_info: Option<ChannelInfo>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl<T> YouTubeListMapper<T> { | impl<T> YouTubeListMapper<T> { | ||||||
|  | @ -479,59 +424,56 @@ impl<T> YouTubeListMapper<T> { | ||||||
|             warnings: Vec::new(), |             warnings: Vec::new(), | ||||||
|             ctoken: None, |             ctoken: None, | ||||||
|             corrected_query: None, |             corrected_query: None, | ||||||
|  |             channel_info: None, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn with_channel<C>(lang: Language, channel: &Channel<C>, warnings: Vec<String>) -> Self { |     pub fn with_channel<C>(lang: Language, channel: &Channel<C>) -> Self { | ||||||
|         Self { |         Self { | ||||||
|             lang, |             lang, | ||||||
|             channel: Some(ChannelTag { |             channel: Some(ChannelTag { | ||||||
|                 id: channel.id.clone(), |                 id: channel.id.to_owned(), | ||||||
|                 name: channel.name.clone(), |                 name: channel.name.to_owned(), | ||||||
|                 avatar: Vec::new(), |                 avatar: Vec::new(), | ||||||
|                 verification: channel.verification, |                 verification: channel.verification, | ||||||
|                 subscriber_count: channel.subscriber_count, |                 subscriber_count: channel.subscriber_count, | ||||||
|             }), |             }), | ||||||
|             items: Vec::new(), |             items: Vec::new(), | ||||||
|             warnings, |             warnings: Vec::new(), | ||||||
|             ctoken: None, |             ctoken: None, | ||||||
|             corrected_query: None, |             corrected_query: None, | ||||||
|  |             channel_info: None, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fn map_video(&mut self, video: VideoRenderer) -> VideoItem { |     fn map_video(&mut self, video: VideoRenderer) -> VideoItem { | ||||||
|         let is_live = video.thumbnail_overlays.is_live() || video.badges.is_live(); |         let mut tn_overlays = video.thumbnail_overlays; | ||||||
|         let is_short = video.thumbnail_overlays.is_short(); |  | ||||||
| 
 |  | ||||||
|         let length_text = video.length_text.or_else(|| { |         let length_text = video.length_text.or_else(|| { | ||||||
|             video |             tn_overlays | ||||||
|                 .thumbnail_overlays |                 .try_swap_remove(0) | ||||||
|                 .into_iter() |                 .map(|overlay| overlay.thumbnail_overlay_time_status_renderer.text) | ||||||
|                 .find(|ol| { |  | ||||||
|                     ol.thumbnail_overlay_time_status_renderer.style == TimeOverlayStyle::Default |  | ||||||
|                 }) |  | ||||||
|                 .map(|ol| ol.thumbnail_overlay_time_status_renderer.text) |  | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         VideoItem { |         VideoItem { | ||||||
|             id: video.video_id, |             id: video.video_id, | ||||||
|             name: video.title, |             name: video.title, | ||||||
|             duration: length_text.and_then(|txt| util::parse_video_length(&txt)), |             length: length_text.and_then(|txt| util::parse_video_length(&txt)), | ||||||
|             thumbnail: video.thumbnail.into(), |             thumbnail: video.thumbnail.into(), | ||||||
|             channel: video |             channel: video | ||||||
|                 .channel |                 .channel | ||||||
|                 .and_then(|c| ChannelTag::try_from(c).ok()) |                 .and_then(|c| { | ||||||
|                 .map(|mut c| { |                     ChannelId::try_from(c).ok().map(|c| ChannelTag { | ||||||
|                     c.avatar = video |                         id: c.id, | ||||||
|  |                         name: c.name, | ||||||
|  |                         avatar: video | ||||||
|                             .channel_thumbnail_supported_renderers |                             .channel_thumbnail_supported_renderers | ||||||
|                             .map(|tn| tn.channel_thumbnail_with_link_renderer.thumbnail) |                             .map(|tn| tn.channel_thumbnail_with_link_renderer.thumbnail) | ||||||
|                             .or(video.channel_thumbnail) |                             .or(video.channel_thumbnail) | ||||||
|                             .unwrap_or_default() |                             .unwrap_or_default() | ||||||
|                         .into(); |                             .into(), | ||||||
|                     if !c.verification.verified() { |                         verification: video.owner_badges.into(), | ||||||
|                         c.verification = video.owner_badges.into(); |                         subscriber_count: None, | ||||||
|                     } |                     }) | ||||||
|                     c |  | ||||||
|                 }) |                 }) | ||||||
|                 .or_else(|| self.channel.clone()), |                 .or_else(|| self.channel.clone()), | ||||||
|             publish_date: video |             publish_date: video | ||||||
|  | @ -547,17 +489,20 @@ impl<T> YouTubeListMapper<T> { | ||||||
|             view_count: video |             view_count: video | ||||||
|                 .view_count_text |                 .view_count_text | ||||||
|                 .map(|txt| util::parse_numeric(&txt).unwrap_or_default()), |                 .map(|txt| util::parse_numeric(&txt).unwrap_or_default()), | ||||||
|             is_live, |             is_live: tn_overlays.is_live() || video.badges.is_live(), | ||||||
|             is_short, |             is_short: tn_overlays.is_short(), | ||||||
|             is_upcoming: video.upcoming_event_data.is_some(), |             is_upcoming: video.upcoming_event_data.is_some(), | ||||||
|             short_description: video |             short_description: video | ||||||
|                 .detailed_metadata_snippets |                 .detailed_metadata_snippets | ||||||
|                 .and_then(|snippets| snippets.into_iter().next().map(|s| s.snippet_text)) |                 .and_then(|mut snippets| snippets.try_swap_remove(0).map(|s| s.snippet_text)) | ||||||
|                 .or(video.description_snippet), |                 .or(video.description_snippet), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fn map_short_video(&mut self, video: ReelItemRenderer) -> VideoItem { |     fn map_short_video(&mut self, video: ReelItemRenderer, lang: Language) -> VideoItem { | ||||||
|  |         static ACCESSIBILITY_SEP_REGEX: Lazy<Regex> = | ||||||
|  |             Lazy::new(|| Regex::new(" [-\u{2013}] (.+) [-\u{2013}] ").unwrap()); | ||||||
|  | 
 | ||||||
|         let pub_date_txt = video.navigation_endpoint.map(|n| { |         let pub_date_txt = video.navigation_endpoint.map(|n| { | ||||||
|             n.reel_watch_endpoint |             n.reel_watch_endpoint | ||||||
|                 .overlay |                 .overlay | ||||||
|  | @ -570,16 +515,23 @@ impl<T> YouTubeListMapper<T> { | ||||||
|         VideoItem { |         VideoItem { | ||||||
|             id: video.video_id, |             id: video.video_id, | ||||||
|             name: video.headline, |             name: video.headline, | ||||||
|             duration: None, |             length: video.accessibility.and_then(|acc| { | ||||||
|  |                 ACCESSIBILITY_SEP_REGEX.captures(&acc).and_then(|cap| { | ||||||
|  |                     cap.get(1).and_then(|c| { | ||||||
|  |                         timeago::parse_timeago_or_warn(self.lang, c.as_str(), &mut self.warnings) | ||||||
|  |                             .map(|ta| Duration::from(ta).whole_seconds() as u32) | ||||||
|  |                     }) | ||||||
|  |                 }) | ||||||
|  |             }), | ||||||
|             thumbnail: video.thumbnail.into(), |             thumbnail: video.thumbnail.into(), | ||||||
|             channel: self.channel.clone(), |             channel: self.channel.clone(), | ||||||
|             publish_date: pub_date_txt.as_ref().and_then(|txt| { |             publish_date: pub_date_txt.as_ref().and_then(|txt| { | ||||||
|                 timeago::parse_timeago_dt_or_warn(self.lang, txt, &mut self.warnings) |                 timeago::parse_timeago_dt_or_warn(self.lang, txt, &mut self.warnings) | ||||||
|             }), |             }), | ||||||
|             publish_date_txt: pub_date_txt, |             publish_date_txt: pub_date_txt, | ||||||
|             view_count: video.view_count_text.and_then(|txt| { |             view_count: video | ||||||
|                 util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings) |                 .view_count_text | ||||||
|             }), |                 .map(|txt| util::parse_large_numstr(&txt, lang).unwrap_or_default()), | ||||||
|             is_live: false, |             is_live: false, | ||||||
|             is_short: true, |             is_short: true, | ||||||
|             is_upcoming: false, |             is_upcoming: false, | ||||||
|  | @ -587,84 +539,6 @@ 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 mut video_info = video.video_info.into_iter(); |  | ||||||
|         let video_info1 = video_info |  | ||||||
|             .next() |  | ||||||
|             .map(|s| match video_info.next().as_deref() { |  | ||||||
|                 None | Some(util::DOT_SEPARATOR) => s, |  | ||||||
|                 Some(s2) => s + s2, |  | ||||||
|             }); |  | ||||||
|         let video_info2 = video_info.next(); |  | ||||||
| 
 |  | ||||||
|         // RU: "7 лет назад" " • " "210 млн просмотров" (order flipped)
 |  | ||||||
|         let (view_count_txt, publish_date_txt) = |  | ||||||
|             if self.lang == Language::Ru && video_info2.is_some() { |  | ||||||
|                 (video_info2, video_info1) |  | ||||||
|             } else { |  | ||||||
|                 (video_info1, video_info2) |  | ||||||
|             }; |  | ||||||
| 
 |  | ||||||
|         let is_live = video.thumbnail_overlays.is_live(); |  | ||||||
| 
 |  | ||||||
|         let publish_date = video |  | ||||||
|             .upcoming_event_data |  | ||||||
|             .as_ref() |  | ||||||
|             .and_then(|upc| OffsetDateTime::from_unix_timestamp(upc.start_time).ok()) |  | ||||||
|             .or_else(|| { |  | ||||||
|                 if is_live { |  | ||||||
|                     None |  | ||||||
|                 } else { |  | ||||||
|                     publish_date_txt.as_ref().and_then(|txt| { |  | ||||||
|                         timeago::parse_timeago_dt_or_warn(self.lang, txt, &mut self.warnings) |  | ||||||
|                     }) |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
| 
 |  | ||||||
|         VideoItem { |  | ||||||
|             id: video.video_id, |  | ||||||
|             name: video.title, |  | ||||||
|             duration: video.length_seconds, |  | ||||||
|             thumbnail: video.thumbnail.into(), |  | ||||||
|             channel, |  | ||||||
|             publish_date, |  | ||||||
|             publish_date_txt, |  | ||||||
|             view_count: view_count_txt.and_then(|txt| { |  | ||||||
|                 util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings) |  | ||||||
|             }), |  | ||||||
|             is_live, |  | ||||||
|             is_short: video.thumbnail_overlays.is_short(), |  | ||||||
|             is_upcoming: video.upcoming_event_data.is_some(), |  | ||||||
|             short_description: None, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     fn map_playlist(&self, playlist: PlaylistRenderer) -> PlaylistItem { |     fn map_playlist(&self, playlist: PlaylistRenderer) -> PlaylistItem { | ||||||
|         PlaylistItem { |         PlaylistItem { | ||||||
|             id: playlist.playlist_id, |             id: playlist.playlist_id, | ||||||
|  | @ -676,12 +550,14 @@ impl<T> YouTubeListMapper<T> { | ||||||
|                 .into(), |                 .into(), | ||||||
|             channel: playlist |             channel: playlist | ||||||
|                 .channel |                 .channel | ||||||
|                 .and_then(|c| ChannelTag::try_from(c).ok()) |                 .and_then(|c| { | ||||||
|                 .map(|mut c| { |                     ChannelId::try_from(c).ok().map(|c| ChannelTag { | ||||||
|                     if !c.verification.verified() { |                         id: c.id, | ||||||
|                         c.verification = playlist.owner_badges.into(); |                         name: c.name, | ||||||
|                     } |                         avatar: Vec::new(), | ||||||
|                     c |                         verification: playlist.owner_badges.into(), | ||||||
|  |                         subscriber_count: None, | ||||||
|  |                     }) | ||||||
|                 }) |                 }) | ||||||
|                 .or_else(|| self.channel.clone()), |                 .or_else(|| self.channel.clone()), | ||||||
|             video_count: playlist.video_count.or_else(|| { |             video_count: playlist.video_count.or_else(|| { | ||||||
|  | @ -694,112 +570,28 @@ impl<T> YouTubeListMapper<T> { | ||||||
| 
 | 
 | ||||||
|     fn map_channel(&mut self, channel: ChannelRenderer) -> ChannelItem { |     fn map_channel(&mut self, channel: ChannelRenderer) -> ChannelItem { | ||||||
|         // channel handle instead of subscriber count (A/B test 3)
 |         // channel handle instead of subscriber count (A/B test 3)
 | ||||||
|         let (handle, sc_txt) = if channel |         let (sc_txt, vc_text) = match channel | ||||||
|             .subscriber_count_text |             .subscriber_count_text | ||||||
|             .as_ref() |             .as_ref() | ||||||
|             .map(|txt| txt.starts_with('@')) |             .map(|txt| txt.starts_with('@')) | ||||||
|             .unwrap_or_default() |             .unwrap_or_default() | ||||||
|         { |         { | ||||||
|             (channel.subscriber_count_text, channel.video_count_text) |             true => (channel.video_count_text, None), | ||||||
|         } else { |             false => (channel.subscriber_count_text, channel.video_count_text), | ||||||
|             (None, channel.subscriber_count_text) |  | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         ChannelItem { |         ChannelItem { | ||||||
|             id: channel.channel_id, |             id: channel.channel_id, | ||||||
|             name: channel.title, |             name: channel.title, | ||||||
|             handle, |  | ||||||
|             avatar: channel.thumbnail.into(), |             avatar: channel.thumbnail.into(), | ||||||
|             verification: channel.owner_badges.into(), |             verification: channel.owner_badges.into(), | ||||||
|             subscriber_count: sc_txt.and_then(|txt| { |             subscriber_count: sc_txt | ||||||
|                 util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings) |                 .and_then(|txt| util::parse_numeric_or_warn(&txt, &mut self.warnings)), | ||||||
|             }), |             video_count: vc_text | ||||||
|  |                 .and_then(|txt| util::parse_numeric_or_warn(&txt, &mut self.warnings)), | ||||||
|             short_description: channel.description_snippet, |             short_description: channel.description_snippet, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     fn map_lockup(&mut self, lockup: LockupViewModel) -> Option<YouTubeItem> { |  | ||||||
|         let md = lockup.metadata.lockup_metadata_view_model; |  | ||||||
|         let tn = lockup.content_image.into_image(); |  | ||||||
|         match lockup.content_type { |  | ||||||
|             LockupContentType::LockupContentTypePlaylist => { |  | ||||||
|                 Some(YouTubeItem::Playlist(PlaylistItem { |  | ||||||
|                     id: lockup.content_id, |  | ||||||
|                     name: md.title, |  | ||||||
|                     thumbnail: tn.image.into(), |  | ||||||
|                     channel: self.channel.clone(), |  | ||||||
|                     video_count: tn |  | ||||||
|                         .overlays |  | ||||||
|                         .first() |  | ||||||
|                         .and_then(|ol| { |  | ||||||
|                             ol.thumbnail_overlay_badge_view_model |  | ||||||
|                                 .thumbnail_badges |  | ||||||
|                                 .first() |  | ||||||
|                         }) |  | ||||||
|                         .and_then(|badge| { |  | ||||||
|                             util::parse_numeric(&badge.thumbnail_badge_view_model.text).ok() |  | ||||||
|                         }), |  | ||||||
|                 })) |  | ||||||
|             } |  | ||||||
|             LockupContentType::LockupContentTypeVideo => { |  | ||||||
|                 let mut mdr = md |  | ||||||
|                     .metadata |  | ||||||
|                     .content_metadata_view_model |  | ||||||
|                     .metadata_rows |  | ||||||
|                     .into_iter(); |  | ||||||
|                 let channel = mdr |  | ||||||
|                     .next() |  | ||||||
|                     .and_then(|r| r.metadata_parts.into_iter().next()) |  | ||||||
|                     .and_then(|p| ChannelTag::try_from(p.into_text_component()).ok()); |  | ||||||
|                 let (view_count, publish_date_txt) = mdr |  | ||||||
|                     .next() |  | ||||||
|                     .map(|metadata_row| { |  | ||||||
|                         let mut parts = metadata_row.metadata_parts.into_iter(); |  | ||||||
|                         let p1 = parts.next(); |  | ||||||
|                         let p2 = parts.next(); |  | ||||||
|                         ( |  | ||||||
|                             p1.and_then(|p| { |  | ||||||
|                                 util::parse_large_numstr_or_warn( |  | ||||||
|                                     p.as_str(), |  | ||||||
|                                     self.lang, |  | ||||||
|                                     &mut self.warnings, |  | ||||||
|                                 ) |  | ||||||
|                             }), |  | ||||||
|                             p2.map(|p2| p2.into_text_component().into_string()), |  | ||||||
|                         ) |  | ||||||
|                     }) |  | ||||||
|                     .unwrap_or_default(); |  | ||||||
| 
 |  | ||||||
|                 Some(YouTubeItem::Video(VideoItem { |  | ||||||
|                     id: lockup.content_id, |  | ||||||
|                     name: md.title, |  | ||||||
|                     duration: tn |  | ||||||
|                         .overlays |  | ||||||
|                         .first() |  | ||||||
|                         .and_then(|ol| { |  | ||||||
|                             ol.thumbnail_overlay_badge_view_model |  | ||||||
|                                 .thumbnail_badges |  | ||||||
|                                 .first() |  | ||||||
|                         }) |  | ||||||
|                         .and_then(|badge| { |  | ||||||
|                             util::parse_video_length(&badge.thumbnail_badge_view_model.text) |  | ||||||
|                         }), |  | ||||||
|                     thumbnail: tn.image.into(), |  | ||||||
|                     channel, |  | ||||||
|                     publish_date: publish_date_txt.as_deref().and_then(|t| { |  | ||||||
|                         timeago::parse_timeago_dt_or_warn(self.lang, t, &mut self.warnings) |  | ||||||
|                     }), |  | ||||||
|                     publish_date_txt, |  | ||||||
|                     view_count, |  | ||||||
|                     is_live: false, |  | ||||||
|                     is_short: false, |  | ||||||
|                     is_upcoming: false, |  | ||||||
|                     short_description: None, |  | ||||||
|                 })) |  | ||||||
|             } |  | ||||||
|             LockupContentType::Unknown => None, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl YouTubeListMapper<YouTubeItem> { | impl YouTubeListMapper<YouTubeItem> { | ||||||
|  | @ -809,17 +601,8 @@ impl YouTubeListMapper<YouTubeItem> { | ||||||
|                 let mapped = YouTubeItem::Video(self.map_video(video)); |                 let mapped = YouTubeItem::Video(self.map_video(video)); | ||||||
|                 self.items.push(mapped); |                 self.items.push(mapped); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::ShortsLockupViewModel(video) => { |  | ||||||
|                 if let Some(mapped) = self.map_short_video2(video) { |  | ||||||
|                     self.items.push(YouTubeItem::Video(mapped)); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             YouTubeListItem::ReelItemRenderer(video) => { |             YouTubeListItem::ReelItemRenderer(video) => { | ||||||
|                 let mapped = self.map_short_video(video); |                 let mapped = self.map_short_video(video, self.lang); | ||||||
|                 self.items.push(YouTubeItem::Video(mapped)); |  | ||||||
|             } |  | ||||||
|             YouTubeListItem::PlaylistVideoRenderer(video) => { |  | ||||||
|                 let mapped = self.map_playlist_video(video); |  | ||||||
|                 self.items.push(YouTubeItem::Video(mapped)); |                 self.items.push(YouTubeItem::Video(mapped)); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::PlaylistRenderer(playlist) => { |             YouTubeListItem::PlaylistRenderer(playlist) => { | ||||||
|  | @ -830,27 +613,42 @@ impl YouTubeListMapper<YouTubeItem> { | ||||||
|                 let mapped = YouTubeItem::Channel(self.map_channel(channel)); |                 let mapped = YouTubeItem::Channel(self.map_channel(channel)); | ||||||
|                 self.items.push(mapped); |                 self.items.push(mapped); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::LockupViewModel(lockup) => { |             YouTubeListItem::ContinuationItemRenderer { | ||||||
|                 if let Some(mapped) = self.map_lockup(lockup) { |                 continuation_endpoint, | ||||||
|                     self.items.push(mapped); |             } => self.ctoken = Some(continuation_endpoint.continuation_command.token), | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             YouTubeListItem::ContinuationItemRenderer(r) => { |  | ||||||
|                 if self.ctoken.is_none() { |  | ||||||
|                     self.ctoken = r.continuation_endpoint.into_token(); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { |             YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { | ||||||
|                 self.corrected_query = Some(corrected_query); |                 self.corrected_query = Some(corrected_query); | ||||||
|             } |             } | ||||||
|  |             YouTubeListItem::ChannelAboutFullMetadataRenderer(meta) => { | ||||||
|  |                 self.channel_info = Some(ChannelInfo { | ||||||
|  |                     create_date: timeago::parse_textual_date_or_warn( | ||||||
|  |                         self.lang, | ||||||
|  |                         &meta.joined_date_text, | ||||||
|  |                         &mut self.warnings, | ||||||
|  |                     ) | ||||||
|  |                     .map(OffsetDateTime::date), | ||||||
|  |                     view_count: meta | ||||||
|  |                         .view_count_text | ||||||
|  |                         .and_then(|txt| util::parse_numeric_or_warn(&txt, &mut self.warnings)), | ||||||
|  |                     links: meta | ||||||
|  |                         .primary_links | ||||||
|  |                         .into_iter() | ||||||
|  |                         .filter_map(|l| { | ||||||
|  |                             l.navigation_endpoint | ||||||
|  |                                 .url_endpoint | ||||||
|  |                                 .map(|url| (l.title, util::sanitize_yt_url(&url.url))) | ||||||
|  |                         }) | ||||||
|  |                         .collect(), | ||||||
|  |                 }) | ||||||
|  |             } | ||||||
|             YouTubeListItem::RichItemRenderer { content } => { |             YouTubeListItem::RichItemRenderer { content } => { | ||||||
|                 self.map_item(*content); |                 self.map_item(*content); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::ItemSectionRenderer { mut contents, .. } => { |             YouTubeListItem::ItemSectionRenderer { mut contents } => { | ||||||
|                 self.warnings.append(&mut contents.warnings); |                 self.warnings.append(&mut contents.warnings); | ||||||
|                 contents.c.into_iter().for_each(|it| self.map_item(it)); |                 contents.c.into_iter().for_each(|it| self.map_item(it)); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::None | YouTubeListItem::ChannelAgeGateRenderer { .. } => {} |             YouTubeListItem::None => {} | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -868,35 +666,19 @@ impl YouTubeListMapper<VideoItem> { | ||||||
|                 self.items.push(mapped); |                 self.items.push(mapped); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::ReelItemRenderer(video) => { |             YouTubeListItem::ReelItemRenderer(video) => { | ||||||
|                 let mapped = self.map_short_video(video); |                 let mapped = self.map_short_video(video, self.lang); | ||||||
|                 self.items.push(mapped); |                 self.items.push(mapped); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::ShortsLockupViewModel(video) => { |             YouTubeListItem::ContinuationItemRenderer { | ||||||
|                 if let Some(mapped) = self.map_short_video2(video) { |                 continuation_endpoint, | ||||||
|                     self.items.push(mapped); |             } => self.ctoken = Some(continuation_endpoint.continuation_command.token), | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             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(r) => { |  | ||||||
|                 if self.ctoken.is_none() { |  | ||||||
|                     self.ctoken = r.continuation_endpoint.into_token(); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { |             YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { | ||||||
|                 self.corrected_query = Some(corrected_query); |                 self.corrected_query = Some(corrected_query); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::RichItemRenderer { content } => { |             YouTubeListItem::RichItemRenderer { content } => { | ||||||
|                 self.map_item(*content); |                 self.map_item(*content); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::ItemSectionRenderer { mut contents, .. } => { |             YouTubeListItem::ItemSectionRenderer { mut contents } => { | ||||||
|                 self.warnings.append(&mut contents.warnings); |                 self.warnings.append(&mut contents.warnings); | ||||||
|                 contents.c.into_iter().for_each(|it| self.map_item(it)); |                 contents.c.into_iter().for_each(|it| self.map_item(it)); | ||||||
|             } |             } | ||||||
|  | @ -908,23 +690,6 @@ impl YouTubeListMapper<VideoItem> { | ||||||
|         self.warnings.append(&mut res.warnings); |         self.warnings.append(&mut res.warnings); | ||||||
|         res.c.into_iter().for_each(|item| self.map_item(item)); |         res.c.into_iter().for_each(|item| self.map_item(item)); | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     #[cfg(feature = "userdata")] |  | ||||||
|     pub(crate) fn conv_history_items( |  | ||||||
|         self, |  | ||||||
|         date_txt: Option<String>, |  | ||||||
|         utc_offset: UtcOffset, |  | ||||||
|         res: &mut MapResult<Vec<HistoryItem<VideoItem>>>, |  | ||||||
|     ) { |  | ||||||
|         res.warnings.extend(self.warnings); |  | ||||||
|         res.c.extend(self.items.into_iter().map(|item| HistoryItem { |  | ||||||
|             item, |  | ||||||
|             playback_date: date_txt.as_deref().and_then(|s| { |  | ||||||
|                 timeago::parse_textual_date_to_d(self.lang, utc_offset, s, &mut res.warnings) |  | ||||||
|             }), |  | ||||||
|             playback_date_txt: date_txt.clone(), |  | ||||||
|         })); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl YouTubeListMapper<PlaylistItem> { | impl YouTubeListMapper<PlaylistItem> { | ||||||
|  | @ -932,25 +697,18 @@ impl YouTubeListMapper<PlaylistItem> { | ||||||
|         match item { |         match item { | ||||||
|             YouTubeListItem::PlaylistRenderer(playlist) => { |             YouTubeListItem::PlaylistRenderer(playlist) => { | ||||||
|                 let mapped = self.map_playlist(playlist); |                 let mapped = self.map_playlist(playlist); | ||||||
|                 self.items.push(mapped); |                 self.items.push(mapped) | ||||||
|             } |  | ||||||
|             YouTubeListItem::LockupViewModel(lockup) => { |  | ||||||
|                 if let Some(YouTubeItem::Playlist(mapped)) = self.map_lockup(lockup) { |  | ||||||
|                     self.items.push(mapped); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             YouTubeListItem::ContinuationItemRenderer(r) => { |  | ||||||
|                 if self.ctoken.is_none() { |  | ||||||
|                     self.ctoken = r.continuation_endpoint.into_token(); |  | ||||||
|                 } |  | ||||||
|             } |             } | ||||||
|  |             YouTubeListItem::ContinuationItemRenderer { | ||||||
|  |                 continuation_endpoint, | ||||||
|  |             } => self.ctoken = Some(continuation_endpoint.continuation_command.token), | ||||||
|             YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { |             YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { | ||||||
|                 self.corrected_query = Some(corrected_query); |                 self.corrected_query = Some(corrected_query); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::RichItemRenderer { content } => { |             YouTubeListItem::RichItemRenderer { content } => { | ||||||
|                 self.map_item(*content); |                 self.map_item(*content); | ||||||
|             } |             } | ||||||
|             YouTubeListItem::ItemSectionRenderer { mut contents, .. } => { |             YouTubeListItem::ItemSectionRenderer { mut contents } => { | ||||||
|                 self.warnings.append(&mut contents.warnings); |                 self.warnings.append(&mut contents.warnings); | ||||||
|                 contents.c.into_iter().for_each(|it| self.map_item(it)); |                 contents.c.into_iter().for_each(|it| self.map_item(it)); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  | @ -1,37 +1,33 @@ | ||||||
| use std::fmt::Debug; | use std::borrow::Cow; | ||||||
| 
 | 
 | ||||||
| use serde::Serialize; | use serde::{de::IgnoredAny, Serialize}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     error::{Error, ExtractionError}, |     error::{Error, ExtractionError}, | ||||||
|     model::{ |     model::{paginator::Paginator, SearchResult, YouTubeItem}, | ||||||
|         paginator::{ContinuationEndpoint, Paginator}, |  | ||||||
|         traits::FromYtItem, |  | ||||||
|         SearchResult, YouTubeItem, |  | ||||||
|     }, |  | ||||||
|     param::search_filter::SearchFilter, |     param::search_filter::SearchFilter, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{response, ClientType, MapRespCtx, MapResponse, MapResult, RustyPipeQuery}; | use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext}; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct QSearch<'a> { | struct QSearch<'a> { | ||||||
|  |     context: YTContext<'a>, | ||||||
|     query: &'a str, |     query: &'a str, | ||||||
|     params: &'a str, |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|  |     params: Option<String>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl RustyPipeQuery { | impl RustyPipeQuery { | ||||||
|     /// Search YouTube
 |     /// Search YouTube
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn search<S: AsRef<str>>(&self, query: S) -> Result<SearchResult, Error> { | ||||||
|     pub async fn search<T: FromYtItem, S: AsRef<str> + Debug>( |  | ||||||
|         &self, |  | ||||||
|         query: S, |  | ||||||
|     ) -> Result<SearchResult<T>, Error> { |  | ||||||
|         let query = query.as_ref(); |         let query = query.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::Desktop, true, None).await; | ||||||
|         let request_body = QSearch { |         let request_body = QSearch { | ||||||
|  |             context, | ||||||
|             query, |             query, | ||||||
|             params: "8AEB", |             params: None, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         self.execute_request::<response::Search, _, _>( |         self.execute_request::<response::Search, _, _>( | ||||||
|  | @ -45,16 +41,17 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Search YouTube using the given [`SearchFilter`]
 |     /// Search YouTube using the given [`SearchFilter`]
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn search_filter<S: AsRef<str>>( | ||||||
|     pub async fn search_filter<T: FromYtItem, S: AsRef<str> + Debug>( |  | ||||||
|         &self, |         &self, | ||||||
|         query: S, |         query: S, | ||||||
|         filter: &SearchFilter, |         filter: &SearchFilter, | ||||||
|     ) -> Result<SearchResult<T>, Error> { |     ) -> Result<SearchResult, Error> { | ||||||
|         let query = query.as_ref(); |         let query = query.as_ref(); | ||||||
|  |         let context = self.get_context(ClientType::Desktop, true, None).await; | ||||||
|         let request_body = QSearch { |         let request_body = QSearch { | ||||||
|  |             context, | ||||||
|             query, |             query, | ||||||
|             params: &filter.encode(), |             params: Some(filter.encode()), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         self.execute_request::<response::Search, _, _>( |         self.execute_request::<response::Search, _, _>( | ||||||
|  | @ -68,38 +65,40 @@ impl RustyPipeQuery { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get YouTube search suggestions
 |     /// Get YouTube search suggestions
 | ||||||
|     #[tracing::instrument(skip(self), level = "error")] |     pub async fn search_suggestion<S: AsRef<str>>(&self, query: S) -> Result<Vec<String>, Error> { | ||||||
|     pub async fn search_suggestion<S: AsRef<str> + Debug>( |         let url = url::Url::parse_with_params("https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&gs_rn=64&gs_ri=youtube&ds=yt&cp=1&gs_id=4&xhr=t&xssi=t", | ||||||
|         &self, |             &[("hl", self.opts.lang.to_string()), ("gl", self.opts.country.to_string()), ("q", query.as_ref().to_owned())] | ||||||
|         query: S, |         ).map_err(|_| Error::Other("could not build url".into()))?; | ||||||
|     ) -> Result<Vec<String>, Error> { |  | ||||||
|         let url = url::Url::parse_with_params( |  | ||||||
|             "https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&xhr=t", |  | ||||||
|             &[ |  | ||||||
|                 ("hl", self.opts.lang.to_string()), |  | ||||||
|                 ("gl", self.opts.country.to_string()), |  | ||||||
|                 ("q", query.as_ref().to_owned()), |  | ||||||
|             ], |  | ||||||
|         ) |  | ||||||
|         .map_err(|_| Error::Other("could not build url".into()))?; |  | ||||||
| 
 | 
 | ||||||
|         let response = self |         let response = self | ||||||
|             .client |             .client | ||||||
|             .http_request_txt(&self.client.inner.http.get(url).build()?) |             .http_request_txt(self.client.inner.http.get(url).build()?) | ||||||
|             .await?; |             .await?; | ||||||
| 
 | 
 | ||||||
|         let parsed = serde_json::from_str::<response::SearchSuggestion>(&response) |         let trimmed = response | ||||||
|  |             .get(5..) | ||||||
|  |             .ok_or(Error::Extraction(ExtractionError::InvalidData( | ||||||
|  |                 Cow::Borrowed("could not get string slice"), | ||||||
|  |             )))?; | ||||||
|  | 
 | ||||||
|  |         let parsed = serde_json::from_str::<( | ||||||
|  |             IgnoredAny, | ||||||
|  |             Vec<(String, IgnoredAny, IgnoredAny)>, | ||||||
|  |             IgnoredAny, | ||||||
|  |         )>(trimmed) | ||||||
|         .map_err(|e| Error::Extraction(ExtractionError::InvalidData(e.to_string().into())))?; |         .map_err(|e| Error::Extraction(ExtractionError::InvalidData(e.to_string().into())))?; | ||||||
| 
 | 
 | ||||||
|         Ok(parsed.1.into_iter().map(|item| item.0).collect()) |         Ok(parsed.1.into_iter().map(|item| item.0).collect()) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search { | impl MapResponse<SearchResult> for response::Search { | ||||||
|     fn map_response( |     fn map_response( | ||||||
|         self, |         self, | ||||||
|         ctx: &MapRespCtx<'_>, |         _id: &str, | ||||||
|     ) -> Result<MapResult<SearchResult<T>>, ExtractionError> { |         lang: crate::param::Language, | ||||||
|  |         _deobf: Option<&crate::deobfuscate::DeobfData>, | ||||||
|  |     ) -> Result<MapResult<SearchResult>, ExtractionError> { | ||||||
|         let items = self |         let items = self | ||||||
|             .contents |             .contents | ||||||
|             .two_column_search_results_renderer |             .two_column_search_results_renderer | ||||||
|  | @ -107,28 +106,20 @@ impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search { | ||||||
|             .section_list_renderer |             .section_list_renderer | ||||||
|             .contents; |             .contents; | ||||||
| 
 | 
 | ||||||
|         let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang); |         let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang); | ||||||
|         mapper.map_response(items); |         mapper.map_response(items); | ||||||
| 
 | 
 | ||||||
|         Ok(MapResult { |         Ok(MapResult { | ||||||
|             c: SearchResult { |             c: SearchResult { | ||||||
|                 items: Paginator::new_ext( |                 items: Paginator::new_ext( | ||||||
|                     self.estimated_results, |                     self.estimated_results, | ||||||
|                     mapper |                     mapper.items, | ||||||
|                         .items |  | ||||||
|                         .into_iter() |  | ||||||
|                         .filter_map(T::from_yt_item) |  | ||||||
|                         .collect(), |  | ||||||
|                     mapper.ctoken, |                     mapper.ctoken, | ||||||
|                     ctx.visitor_data.map(str::to_owned), |                     None, | ||||||
|                     ContinuationEndpoint::Search, |                     crate::model::paginator::ContinuationEndpoint::Search, | ||||||
|                     false, |  | ||||||
|                 ), |                 ), | ||||||
|                 corrected_query: mapper.corrected_query, |                 corrected_query: mapper.corrected_query, | ||||||
|                 visitor_data: self |                 visitor_data: self.response_context.visitor_data, | ||||||
|                     .response_context |  | ||||||
|                     .visitor_data |  | ||||||
|                     .or_else(|| ctx.visitor_data.map(str::to_owned)), |  | ||||||
|             }, |             }, | ||||||
|             warnings: mapper.warnings, |             warnings: mapper.warnings, | ||||||
|         }) |         }) | ||||||
|  | @ -143,8 +134,9 @@ mod tests { | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|     use crate::{ |     use crate::{ | ||||||
|         client::{response, MapRespCtx, MapResponse}, |         client::{response, MapResponse}, | ||||||
|         model::{SearchResult, YouTubeItem}, |         model::SearchResult, | ||||||
|  |         param::Language, | ||||||
|         serializer::MapResult, |         serializer::MapResult, | ||||||
|         util::tests::TESTFILES, |         util::tests::TESTFILES, | ||||||
|     }; |     }; | ||||||
|  | @ -159,8 +151,7 @@ mod tests { | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let search: response::Search = serde_json::from_reader(BufReader::new(json_file)).unwrap(); |         let search: response::Search = serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let map_res: MapResult<SearchResult<YouTubeItem>> = |         let map_res: MapResult<SearchResult> = search.map_response("", Language::En, None).unwrap(); | ||||||
|             search.map_response(&MapRespCtx::test("")).unwrap(); |  | ||||||
| 
 | 
 | ||||||
|         assert!( |         assert!( | ||||||
|             map_res.warnings.is_empty(), |             map_res.warnings.is_empty(), | ||||||
|  |  | ||||||
|  | @ -2,15 +2,152 @@ | ||||||
| source: src/client/channel.rs | source: src/client/channel.rs | ||||||
| expression: map_res.c | expression: map_res.c | ||||||
| --- | --- | ||||||
| ChannelInfo( | Channel( | ||||||
|   id: "UC2DjFE7Xf11URZqWBigcVOQ", |   id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|   url: "http://www.youtube.com/@EEVblog", |   name: "EEVblog", | ||||||
|  |   subscriber_count: Some(881000), | ||||||
|  |   avatar: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", | ||||||
|  |       width: 48, | ||||||
|  |       height: 48, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s88-c-k-c0x00ffffff-no-rj", | ||||||
|  |       width: 88, | ||||||
|  |       height: 88, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s176-c-k-c0x00ffffff-no-rj", | ||||||
|  |       width: 176, | ||||||
|  |       height: 176, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|  |   verification: Verified, | ||||||
|   description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", |   description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", | ||||||
|   subscriber_count: Some(920000), |   tags: [ | ||||||
|   video_count: Some(1920), |     "electronics", | ||||||
|  |     "engineering", | ||||||
|  |     "maker", | ||||||
|  |     "hacker", | ||||||
|  |     "design", | ||||||
|  |     "circuit", | ||||||
|  |     "hardware", | ||||||
|  |     "pic", | ||||||
|  |     "atmel", | ||||||
|  |     "oscilloscope", | ||||||
|  |     "multimeter", | ||||||
|  |     "diy", | ||||||
|  |     "hobby", | ||||||
|  |     "review", | ||||||
|  |     "teardown", | ||||||
|  |     "microcontroller", | ||||||
|  |     "arduino", | ||||||
|  |     "video", | ||||||
|  |     "blog", | ||||||
|  |     "tutorial", | ||||||
|  |     "how-to", | ||||||
|  |     "interview", | ||||||
|  |     "rant", | ||||||
|  |     "industry", | ||||||
|  |     "news", | ||||||
|  |     "mailbag", | ||||||
|  |     "dumpster diving", | ||||||
|  |     "debunking", | ||||||
|  |   ], | ||||||
|  |   vanity_url: Some("https://www.youtube.com/c/EevblogDave"), | ||||||
|  |   banner: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1060, | ||||||
|  |       height: 175, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1138, | ||||||
|  |       height: 188, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1707, | ||||||
|  |       height: 283, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 2120, | ||||||
|  |       height: 351, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 2276, | ||||||
|  |       height: 377, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 2560, | ||||||
|  |       height: 424, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|  |   mobile_banner: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 320, | ||||||
|  |       height: 88, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 640, | ||||||
|  |       height: 175, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 960, | ||||||
|  |       height: 263, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1280, | ||||||
|  |       height: 351, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1440, | ||||||
|  |       height: 395, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|  |   tv_banner: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 320, | ||||||
|  |       height: 180, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 854, | ||||||
|  |       height: 480, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1280, | ||||||
|  |       height: 720, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1920, | ||||||
|  |       height: 1080, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 2120, | ||||||
|  |       height: 1192, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|  |   has_shorts: false, | ||||||
|  |   has_live: false, | ||||||
|  |   visitor_data: Some("CgszMUUzZDlGLWxiRSipqr2ZBg%3D%3D"), | ||||||
|  |   content: ChannelInfo( | ||||||
|     create_date: Some("2009-04-04"), |     create_date: Some("2009-04-04"), | ||||||
|   view_count: Some(199087682), |     view_count: Some(186854342), | ||||||
|   country: Some(AU), |  | ||||||
|     links: [ |     links: [ | ||||||
|       ("EEVblog Web Site", "http://www.eevblog.com/"), |       ("EEVblog Web Site", "http://www.eevblog.com/"), | ||||||
|       ("Twitter", "http://www.twitter.com/eevblog"), |       ("Twitter", "http://www.twitter.com/eevblog"), | ||||||
|  | @ -26,4 +163,5 @@ ChannelInfo( | ||||||
|       ("EEVblog AMAZON Store", "http://www.amazon.com/gp/redirect.html?ie=UTF8&location=http%3A%2F%2Fwww.amazon.com%2F&tag=ee04-20&linkCode=ur2&camp=1789&creative=390957"), |       ("EEVblog AMAZON Store", "http://www.amazon.com/gp/redirect.html?ie=UTF8&location=http%3A%2F%2Fwww.amazon.com%2F&tag=ee04-20&linkCode=ur2&camp=1789&creative=390957"), | ||||||
|       ("2nd EEVblog Channel", "http://www.youtube.com/EEVblog2"), |       ("2nd EEVblog Channel", "http://www.youtube.com/EEVblog2"), | ||||||
|     ], |     ], | ||||||
|  |   ), | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | @ -5,9 +5,7 @@ expression: map_res.c | ||||||
| Channel( | Channel( | ||||||
|   id: "UC2DjFE7Xf11URZqWBigcVOQ", |   id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|   name: "EEVblog", |   name: "EEVblog", | ||||||
|   handle: None, |  | ||||||
|   subscriber_count: Some(884000), |   subscriber_count: Some(884000), | ||||||
|   video_count: None, |  | ||||||
|   avatar: [ |   avatar: [ | ||||||
|     Thumbnail( |     Thumbnail( | ||||||
|       url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", |       url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", | ||||||
|  | @ -25,7 +23,7 @@ Channel( | ||||||
|       height: 176, |       height: 176, | ||||||
|     ), |     ), | ||||||
|   ], |   ], | ||||||
|   verification: verified, |   verification: Verified, | ||||||
|   description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", |   description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", | ||||||
|   tags: [ |   tags: [ | ||||||
|     "electronics", |     "electronics", | ||||||
|  | @ -57,6 +55,7 @@ Channel( | ||||||
|     "dumpster diving", |     "dumpster diving", | ||||||
|     "debunking", |     "debunking", | ||||||
|   ], |   ], | ||||||
|  |   vanity_url: Some("https://www.youtube.com/c/EevblogDave"), | ||||||
|   banner: [ |   banner: [ | ||||||
|     Thumbnail( |     Thumbnail( | ||||||
|       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  | @ -89,6 +88,60 @@ Channel( | ||||||
|       height: 424, |       height: 424, | ||||||
|     ), |     ), | ||||||
|   ], |   ], | ||||||
|  |   mobile_banner: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 320, | ||||||
|  |       height: 88, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 640, | ||||||
|  |       height: 175, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 960, | ||||||
|  |       height: 263, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1280, | ||||||
|  |       height: 351, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1440, | ||||||
|  |       height: 395, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|  |   tv_banner: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 320, | ||||||
|  |       height: 180, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 854, | ||||||
|  |       height: 480, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1280, | ||||||
|  |       height: 720, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1920, | ||||||
|  |       height: 1080, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 2120, | ||||||
|  |       height: 1192, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|   has_shorts: false, |   has_shorts: false, | ||||||
|   has_live: true, |   has_live: true, | ||||||
|   visitor_data: None, |   visitor_data: None, | ||||||
|  | @ -98,7 +151,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "hhs95CI6Dsg", |         id: "hhs95CI6Dsg", | ||||||
|         name: "MARS 2020 Landing LIVE", |         name: "MARS 2020 Landing LIVE", | ||||||
|         duration: Some(6321), |         length: Some(6321), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/hhs95CI6Dsg/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHUBoAC4AOKAgwIABABGGUgZShlMA8=&rs=AOn4CLAlPp2e1tF8gyf1cJisZGTMleissg", |             url: "https://i.ytimg.com/vi/hhs95CI6Dsg/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHUBoAC4AOKAgwIABABGGUgZShlMA8=&rs=AOn4CLAlPp2e1tF8gyf1cJisZGTMleissg", | ||||||
|  | @ -125,7 +178,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -139,7 +192,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "cpQk2n-wmQ4", |         id: "cpQk2n-wmQ4", | ||||||
|         name: "LIVE Soldering", |         name: "LIVE Soldering", | ||||||
|         duration: Some(7046), |         length: Some(7046), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/cpQk2n-wmQ4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCoS3qwdY2rDbhkWJOWHisORlMKnA", |             url: "https://i.ytimg.com/vi/cpQk2n-wmQ4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCoS3qwdY2rDbhkWJOWHisORlMKnA", | ||||||
|  | @ -166,7 +219,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -180,7 +233,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "kIDV_XN9oA8", |         id: "kIDV_XN9oA8", | ||||||
|         name: "LIVE Soldering", |         name: "LIVE Soldering", | ||||||
|         duration: Some(4353), |         length: Some(4353), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/kIDV_XN9oA8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBG3KVoFpBFIYCG2mrox_kEq6Arug", |             url: "https://i.ytimg.com/vi/kIDV_XN9oA8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBG3KVoFpBFIYCG2mrox_kEq6Arug", | ||||||
|  | @ -207,7 +260,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -221,7 +274,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "DWS4Qp3Yn0A", |         id: "DWS4Qp3Yn0A", | ||||||
|         name: "Apollo 11 Launch LIVE - 50 Years Later", |         name: "Apollo 11 Launch LIVE - 50 Years Later", | ||||||
|         duration: Some(4560), |         length: Some(4560), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/DWS4Qp3Yn0A/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAFkIQ4er8qDNMlD9H8lPzfSnE99g", |             url: "https://i.ytimg.com/vi/DWS4Qp3Yn0A/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAFkIQ4er8qDNMlD9H8lPzfSnE99g", | ||||||
|  | @ -248,7 +301,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -262,7 +315,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "LwjTe3SiVXg", |         id: "LwjTe3SiVXg", | ||||||
|         name: "EEVblog LIVE Q&A", |         name: "EEVblog LIVE Q&A", | ||||||
|         duration: Some(3943), |         length: Some(3943), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/LwjTe3SiVXg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzTlnjBJLT3KJVN4teMlX_svuaNA", |             url: "https://i.ytimg.com/vi/LwjTe3SiVXg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAzTlnjBJLT3KJVN4teMlX_svuaNA", | ||||||
|  | @ -289,7 +342,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -303,7 +356,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "skPiz3GrVNs", |         id: "skPiz3GrVNs", | ||||||
|         name: "LIVE Keysight Scope Draw #2", |         name: "LIVE Keysight Scope Draw #2", | ||||||
|         duration: Some(2445), |         length: Some(2445), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/skPiz3GrVNs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFiIfUBfoL0Q9CLR9Pc8bXy-zclg", |             url: "https://i.ytimg.com/vi/skPiz3GrVNs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBFiIfUBfoL0Q9CLR9Pc8bXy-zclg", | ||||||
|  | @ -330,7 +383,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -344,7 +397,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "HZc-Ctvgv5Y", |         id: "HZc-Ctvgv5Y", | ||||||
|         name: "LIVE Keysight Scope Draw", |         name: "LIVE Keysight Scope Draw", | ||||||
|         duration: Some(6455), |         length: Some(6455), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/HZc-Ctvgv5Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQM1_QPh6u5_BFonLCdFPz-AcpkQ", |             url: "https://i.ytimg.com/vi/HZc-Ctvgv5Y/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQM1_QPh6u5_BFonLCdFPz-AcpkQ", | ||||||
|  | @ -371,7 +424,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -385,7 +438,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "5ilODYy2zGE", |         id: "5ilODYy2zGE", | ||||||
|         name: "Ask Dave LIVE - March 8th 2019", |         name: "Ask Dave LIVE - March 8th 2019", | ||||||
|         duration: Some(10645), |         length: Some(10645), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/5ilODYy2zGE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCft4f7Lw3l3_u55bzUibWXr-UHTQ", |             url: "https://i.ytimg.com/vi/5ilODYy2zGE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCft4f7Lw3l3_u55bzUibWXr-UHTQ", | ||||||
|  | @ -412,7 +465,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -426,7 +479,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "gQ7TTuiDH1M", |         id: "gQ7TTuiDH1M", | ||||||
|         name: "Ask Dave LIVE - Jan 28th 2019", |         name: "Ask Dave LIVE - Jan 28th 2019", | ||||||
|         duration: Some(17228), |         length: Some(17228), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/gQ7TTuiDH1M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAUPZz1xzckl5xzdBRonA_1WNWIyg", |             url: "https://i.ytimg.com/vi/gQ7TTuiDH1M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAUPZz1xzckl5xzdBRonA_1WNWIyg", | ||||||
|  | @ -453,7 +506,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -467,7 +520,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "qpw9dKxL2Ho", |         id: "qpw9dKxL2Ho", | ||||||
|         name: "LIVE KiCAD 5 PCB Design", |         name: "LIVE KiCAD 5 PCB Design", | ||||||
|         duration: Some(8003), |         length: Some(8003), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/qpw9dKxL2Ho/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAC-kI2770I7JgVCTYExG0vXoYoxA", |             url: "https://i.ytimg.com/vi/qpw9dKxL2Ho/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAC-kI2770I7JgVCTYExG0vXoYoxA", | ||||||
|  | @ -494,7 +547,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -508,7 +561,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "wECZoUNd2GY", |         id: "wECZoUNd2GY", | ||||||
|         name: "EEVblog LIVE DIY TTL Computer Build", |         name: "EEVblog LIVE DIY TTL Computer Build", | ||||||
|         duration: Some(14599), |         length: Some(14599), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/wECZoUNd2GY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDzZwAD6bQQEaYuZEzmQ0sgQKc1yA", |             url: "https://i.ytimg.com/vi/wECZoUNd2GY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDzZwAD6bQQEaYuZEzmQ0sgQKc1yA", | ||||||
|  | @ -535,7 +588,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -549,7 +602,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "bV99dn-tWDk", |         id: "bV99dn-tWDk", | ||||||
|         name: "EEVblog LIVE Scope Draw", |         name: "EEVblog LIVE Scope Draw", | ||||||
|         duration: Some(2694), |         length: Some(2694), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/bV99dn-tWDk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAR4ckJxAituVMFCyWpYhHXozqQRA", |             url: "https://i.ytimg.com/vi/bV99dn-tWDk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAR4ckJxAituVMFCyWpYhHXozqQRA", | ||||||
|  | @ -576,7 +629,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -590,7 +643,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "-NGRIFiu_p0", |         id: "-NGRIFiu_p0", | ||||||
|         name: "EEVblog LIVE SHOW - End of 2017", |         name: "EEVblog LIVE SHOW - End of 2017", | ||||||
|         duration: Some(12238), |         length: Some(12238), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/-NGRIFiu_p0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjMmIdgjiSMBQ2X73h6-NtVUIqSg", |             url: "https://i.ytimg.com/vi/-NGRIFiu_p0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjMmIdgjiSMBQ2X73h6-NtVUIqSg", | ||||||
|  | @ -617,7 +670,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -631,7 +684,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "zgE6_x4rM5k", |         id: "zgE6_x4rM5k", | ||||||
|         name: "LIVE Show Giveaway", |         name: "LIVE Show Giveaway", | ||||||
|         duration: Some(5533), |         length: Some(5533), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/zgE6_x4rM5k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjb92wUNqOvTKs9TCLCThvdkdz3A", |             url: "https://i.ytimg.com/vi/zgE6_x4rM5k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjb92wUNqOvTKs9TCLCThvdkdz3A", | ||||||
|  | @ -658,7 +711,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -672,7 +725,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "9DjABCJN2M8", |         id: "9DjABCJN2M8", | ||||||
|         name: "LIVE Testing of the Batteriser", |         name: "LIVE Testing of the Batteriser", | ||||||
|         duration: Some(10747), |         length: Some(10747), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/9DjABCJN2M8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBXhnnHCuNfSzHZC64KFsfHPPJDNg", |             url: "https://i.ytimg.com/vi/9DjABCJN2M8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBXhnnHCuNfSzHZC64KFsfHPPJDNg", | ||||||
|  | @ -699,7 +752,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -713,7 +766,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "cAsUI2YhqN4", |         id: "cAsUI2YhqN4", | ||||||
|         name: "LIVE Unboxing of the Batteriser! (Batteroo)", |         name: "LIVE Unboxing of the Batteriser! (Batteroo)", | ||||||
|         duration: Some(3102), |         length: Some(3102), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/cAsUI2YhqN4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCOE1MyG1nFXs9D2qdK78bpN1mc_g", |             url: "https://i.ytimg.com/vi/cAsUI2YhqN4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCOE1MyG1nFXs9D2qdK78bpN1mc_g", | ||||||
|  | @ -740,7 +793,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -754,7 +807,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "CLYKwFMW9J0", |         id: "CLYKwFMW9J0", | ||||||
|         name: "Juno Live Again", |         name: "Juno Live Again", | ||||||
|         duration: Some(811), |         length: Some(811), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/CLYKwFMW9J0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC7WO4HX0e7M58ddoJD5dkVjdKHYQ", |             url: "https://i.ytimg.com/vi/CLYKwFMW9J0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC7WO4HX0e7M58ddoJD5dkVjdKHYQ", | ||||||
|  | @ -781,7 +834,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -795,7 +848,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "nV43vM9VcUA", |         id: "nV43vM9VcUA", | ||||||
|         name: "Juno Live", |         name: "Juno Live", | ||||||
|         duration: Some(190), |         length: Some(190), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/nV43vM9VcUA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy-zEVPDvomCCi8YoP8Ig_Hrhzfw", |             url: "https://i.ytimg.com/vi/nV43vM9VcUA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCy-zEVPDvomCCi8YoP8Ig_Hrhzfw", | ||||||
|  | @ -822,7 +875,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -836,7 +889,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "38uFiWzcDnc", |         id: "38uFiWzcDnc", | ||||||
|         name: "Juno Orbital Insertion Live", |         name: "Juno Orbital Insertion Live", | ||||||
|         duration: Some(1731), |         length: Some(1731), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/38uFiWzcDnc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLALhrDygxFH4T2c-4efZqVaJnYY7g", |             url: "https://i.ytimg.com/vi/38uFiWzcDnc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLALhrDygxFH4T2c-4efZqVaJnYY7g", | ||||||
|  | @ -863,7 +916,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -877,7 +930,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "ib80yjc9VlM", |         id: "ib80yjc9VlM", | ||||||
|         name: "Juno Jupiter Live", |         name: "Juno Jupiter Live", | ||||||
|         duration: Some(581), |         length: Some(581), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/ib80yjc9VlM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDbJJvzoEmwUc7nAm6GLJpoZJKmgQ", |             url: "https://i.ytimg.com/vi/ib80yjc9VlM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDbJJvzoEmwUc7nAm6GLJpoZJKmgQ", | ||||||
|  | @ -904,7 +957,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -918,7 +971,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "rQRakYpb8-g", |         id: "rQRakYpb8-g", | ||||||
|         name: "eevSTREAM: Lab Rearrangement Part 2", |         name: "eevSTREAM: Lab Rearrangement Part 2", | ||||||
|         duration: Some(8616), |         length: Some(8616), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/rQRakYpb8-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAdGJH0yhCQ7kmI3d3JXVv_7xzJAQ", |             url: "https://i.ytimg.com/vi/rQRakYpb8-g/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAdGJH0yhCQ7kmI3d3JXVv_7xzJAQ", | ||||||
|  | @ -945,7 +998,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -959,7 +1012,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "DwLEFKu2XWg", |         id: "DwLEFKu2XWg", | ||||||
|         name: "eevSTREAM: Lab Rearrangement Part 1", |         name: "eevSTREAM: Lab Rearrangement Part 1", | ||||||
|         duration: Some(768), |         length: Some(768), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/DwLEFKu2XWg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCXvSePgZ8NIKQTviqWvROVZFRPpA", |             url: "https://i.ytimg.com/vi/DwLEFKu2XWg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCXvSePgZ8NIKQTviqWvROVZFRPpA", | ||||||
|  | @ -986,7 +1039,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1000,7 +1053,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "VeUDXQR3F2o", |         id: "VeUDXQR3F2o", | ||||||
|         name: "Live Show", |         name: "Live Show", | ||||||
|         duration: Some(10360), |         length: Some(10360), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/VeUDXQR3F2o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDmgrfQXMTaGMahuP8F_UHJAomFbg", |             url: "https://i.ytimg.com/vi/VeUDXQR3F2o/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDmgrfQXMTaGMahuP8F_UHJAomFbg", | ||||||
|  | @ -1027,7 +1080,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1041,7 +1094,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "PgZx25vVwoI", |         id: "PgZx25vVwoI", | ||||||
|         name: "Live Giveaway", |         name: "Live Giveaway", | ||||||
|         duration: Some(1808), |         length: Some(1808), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/PgZx25vVwoI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDTrMmoCfISxG0YSqC4oEyKGHdK_A", |             url: "https://i.ytimg.com/vi/PgZx25vVwoI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDTrMmoCfISxG0YSqC4oEyKGHdK_A", | ||||||
|  | @ -1068,7 +1121,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1082,7 +1135,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "jUtzoO-ur34", |         id: "jUtzoO-ur34", | ||||||
|         name: "Inventables X-Carve LIVE Build Part 4", |         name: "Inventables X-Carve LIVE Build Part 4", | ||||||
|         duration: Some(10665), |         length: Some(10665), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/jUtzoO-ur34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCO35sFP8D_Q08HxMZkNHFO8MmpDg", |             url: "https://i.ytimg.com/vi/jUtzoO-ur34/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCO35sFP8D_Q08HxMZkNHFO8MmpDg", | ||||||
|  | @ -1109,7 +1162,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1123,7 +1176,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "199gtbX1y4M", |         id: "199gtbX1y4M", | ||||||
|         name: "Inventables X-Carve LIVE Build Part 3 + Batteriser Rant", |         name: "Inventables X-Carve LIVE Build Part 3 + Batteriser Rant", | ||||||
|         duration: Some(6267), |         length: Some(6267), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/199gtbX1y4M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg3bMS00xpSXmNn1f5hXu_jWWC1w", |             url: "https://i.ytimg.com/vi/199gtbX1y4M/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAg3bMS00xpSXmNn1f5hXu_jWWC1w", | ||||||
|  | @ -1150,7 +1203,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1164,7 +1217,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "nQH4I_p7-MI", |         id: "nQH4I_p7-MI", | ||||||
|         name: "Inventables X-Carve LIVE Build Part 2", |         name: "Inventables X-Carve LIVE Build Part 2", | ||||||
|         duration: Some(17643), |         length: Some(17643), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/nQH4I_p7-MI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBMIA1YzQefFwGj5UFikXuYS2Nkng", |             url: "https://i.ytimg.com/vi/nQH4I_p7-MI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBMIA1YzQefFwGj5UFikXuYS2Nkng", | ||||||
|  | @ -1191,7 +1244,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1205,7 +1258,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "XBMNFXGKpaw", |         id: "XBMNFXGKpaw", | ||||||
|         name: "Inventables X-Carve LIVE Build", |         name: "Inventables X-Carve LIVE Build", | ||||||
|         duration: Some(5479), |         length: Some(5479), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/XBMNFXGKpaw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCV980wWO8tdx0aFDXwPn9aBQ2xlA", |             url: "https://i.ytimg.com/vi/XBMNFXGKpaw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCV980wWO8tdx0aFDXwPn9aBQ2xlA", | ||||||
|  | @ -1232,7 +1285,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1246,7 +1299,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "yl6DGgiE3J8", |         id: "yl6DGgiE3J8", | ||||||
|         name: "Apollo Saturn LVDC Live testing", |         name: "Apollo Saturn LVDC Live testing", | ||||||
|         duration: Some(1076), |         length: Some(1076), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/yl6DGgiE3J8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCugABHuqqPZQjV9cEm0JFh7R5aiA", |             url: "https://i.ytimg.com/vi/yl6DGgiE3J8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCugABHuqqPZQjV9cEm0JFh7R5aiA", | ||||||
|  | @ -1273,7 +1326,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  | @ -1287,7 +1340,7 @@ Channel( | ||||||
|       VideoItem( |       VideoItem( | ||||||
|         id: "EEMcIZAcKjc", |         id: "EEMcIZAcKjc", | ||||||
|         name: "LIVE EEVblog Mailbag", |         name: "LIVE EEVblog Mailbag", | ||||||
|         duration: Some(7344), |         length: Some(7344), | ||||||
|         thumbnail: [ |         thumbnail: [ | ||||||
|           Thumbnail( |           Thumbnail( | ||||||
|             url: "https://i.ytimg.com/vi/EEMcIZAcKjc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCg16HpJqC9mNwkYOf8b0cfAuNLOA", |             url: "https://i.ytimg.com/vi/EEMcIZAcKjc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCg16HpJqC9mNwkYOf8b0cfAuNLOA", | ||||||
|  | @ -1314,7 +1367,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(884000), |           subscriber_count: Some(884000), | ||||||
|         )), |         )), | ||||||
|         publish_date: "[date]", |         publish_date: "[date]", | ||||||
|  |  | ||||||
|  | @ -5,9 +5,7 @@ expression: map_res.c | ||||||
| Channel( | Channel( | ||||||
|   id: "UC2DjFE7Xf11URZqWBigcVOQ", |   id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|   name: "EEVblog", |   name: "EEVblog", | ||||||
|   handle: None, |  | ||||||
|   subscriber_count: Some(881000), |   subscriber_count: Some(881000), | ||||||
|   video_count: None, |  | ||||||
|   avatar: [ |   avatar: [ | ||||||
|     Thumbnail( |     Thumbnail( | ||||||
|       url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", |       url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj", | ||||||
|  | @ -25,7 +23,7 @@ Channel( | ||||||
|       height: 176, |       height: 176, | ||||||
|     ), |     ), | ||||||
|   ], |   ], | ||||||
|   verification: verified, |   verification: Verified, | ||||||
|   description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", |   description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA", | ||||||
|   tags: [ |   tags: [ | ||||||
|     "electronics", |     "electronics", | ||||||
|  | @ -57,6 +55,7 @@ Channel( | ||||||
|     "dumpster diving", |     "dumpster diving", | ||||||
|     "debunking", |     "debunking", | ||||||
|   ], |   ], | ||||||
|  |   vanity_url: Some("https://www.youtube.com/c/EevblogDave"), | ||||||
|   banner: [ |   banner: [ | ||||||
|     Thumbnail( |     Thumbnail( | ||||||
|       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  | @ -89,6 +88,60 @@ Channel( | ||||||
|       height: 424, |       height: 424, | ||||||
|     ), |     ), | ||||||
|   ], |   ], | ||||||
|  |   mobile_banner: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 320, | ||||||
|  |       height: 88, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 640, | ||||||
|  |       height: 175, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 960, | ||||||
|  |       height: 263, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1280, | ||||||
|  |       height: 351, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1440, | ||||||
|  |       height: 395, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|  |   tv_banner: [ | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 320, | ||||||
|  |       height: 180, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 854, | ||||||
|  |       height: 480, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1280, | ||||||
|  |       height: 720, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 1920, | ||||||
|  |       height: 1080, | ||||||
|  |     ), | ||||||
|  |     Thumbnail( | ||||||
|  |       url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", | ||||||
|  |       width: 2120, | ||||||
|  |       height: 1192, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|   has_shorts: false, |   has_shorts: false, | ||||||
|   has_live: false, |   has_live: false, | ||||||
|   visitor_data: Some("CgttaWpyTVpUN1AyZyioqr2ZBg%3D%3D"), |   visitor_data: Some("CgttaWpyTVpUN1AyZyioqr2ZBg%3D%3D"), | ||||||
|  | @ -109,7 +162,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(2), |         video_count: Some(2), | ||||||
|  | @ -128,7 +181,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(1), |         video_count: Some(1), | ||||||
|  | @ -147,7 +200,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(9), |         video_count: Some(9), | ||||||
|  | @ -166,7 +219,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(2), |         video_count: Some(2), | ||||||
|  | @ -185,7 +238,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(4), |         video_count: Some(4), | ||||||
|  | @ -204,7 +257,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(18), |         video_count: Some(18), | ||||||
|  | @ -223,7 +276,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(3), |         video_count: Some(3), | ||||||
|  | @ -242,7 +295,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(8), |         video_count: Some(8), | ||||||
|  | @ -261,7 +314,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(13), |         video_count: Some(13), | ||||||
|  | @ -280,7 +333,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(9), |         video_count: Some(9), | ||||||
|  | @ -299,7 +352,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(7), |         video_count: Some(7), | ||||||
|  | @ -318,7 +371,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(3), |         video_count: Some(3), | ||||||
|  | @ -337,7 +390,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(8), |         video_count: Some(8), | ||||||
|  | @ -356,7 +409,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(2), |         video_count: Some(2), | ||||||
|  | @ -375,7 +428,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(3), |         video_count: Some(3), | ||||||
|  | @ -394,7 +447,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(10), |         video_count: Some(10), | ||||||
|  | @ -413,7 +466,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(1), |         video_count: Some(1), | ||||||
|  | @ -432,7 +485,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(9), |         video_count: Some(9), | ||||||
|  | @ -451,7 +504,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(16), |         video_count: Some(16), | ||||||
|  | @ -470,7 +523,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(7), |         video_count: Some(7), | ||||||
|  | @ -489,7 +542,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(6), |         video_count: Some(6), | ||||||
|  | @ -508,7 +561,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(12), |         video_count: Some(12), | ||||||
|  | @ -527,7 +580,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(1), |         video_count: Some(1), | ||||||
|  | @ -546,7 +599,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(5), |         video_count: Some(5), | ||||||
|  | @ -565,7 +618,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(2), |         video_count: Some(2), | ||||||
|  | @ -584,7 +637,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(4), |         video_count: Some(4), | ||||||
|  | @ -603,7 +656,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(1), |         video_count: Some(1), | ||||||
|  | @ -622,7 +675,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(2), |         video_count: Some(2), | ||||||
|  | @ -641,7 +694,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(9), |         video_count: Some(9), | ||||||
|  | @ -660,7 +713,7 @@ Channel( | ||||||
|           id: "UC2DjFE7Xf11URZqWBigcVOQ", |           id: "UC2DjFE7Xf11URZqWBigcVOQ", | ||||||
|           name: "EEVblog", |           name: "EEVblog", | ||||||
|           avatar: [], |           avatar: [], | ||||||
|           verification: verified, |           verification: Verified, | ||||||
|           subscriber_count: Some(881000), |           subscriber_count: Some(881000), | ||||||
|         )), |         )), | ||||||
|         video_count: Some(1), |         video_count: Some(1), | ||||||
|  | @ -1,672 +0,0 @@ | ||||||
| --- |  | ||||||
| source: src/client/channel.rs |  | ||||||
| expression: map_res.c |  | ||||||
| --- |  | ||||||
| Channel( |  | ||||||
|   id: "UC2DjFE7Xf11URZqWBigcVOQ", |  | ||||||
|   name: "EEVblog", |  | ||||||
|   handle: Some("@EEVblog"), |  | ||||||
|   subscriber_count: Some(952000), |  | ||||||
|   video_count: Some(2000), |  | ||||||
|   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, |  | ||||||
|   ), |  | ||||||
| ) |  | ||||||