Compare commits
	
		
			5 commits
		
	
	
		
			
				925652acdd
			
			...
			
				8548bc81e9
			
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8548bc81e9 | |||
| 05f609e247 | |||
| 63d2a0fb36 | |||
| b52fd7349b | |||
| dda2211e04 | 
					 50 changed files with 1621 additions and 11547 deletions
				
			
		
							
								
								
									
										5
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -1,6 +1,5 @@ | ||||||
| /target | /target | ||||||
| /Cargo.lock | /Cargo.lock | ||||||
| 
 | 
 | ||||||
| RustyPipeReports | rustypipe_reports | ||||||
| RustyPipeCache.json | rustypipe_cache.json | ||||||
| rusty-tube.json |  | ||||||
|  |  | ||||||
|  | @ -7,12 +7,16 @@ edition = "2021" | ||||||
| members = [".", "cli"] | members = [".", "cli"] | ||||||
| 
 | 
 | ||||||
| [features] | [features] | ||||||
| default = ["default-tls"] | default = ["default-tls", "yaml"] | ||||||
| 
 | 
 | ||||||
|  | # Reqwest TLS | ||||||
| default-tls = ["reqwest/default-tls"] | default-tls = ["reqwest/default-tls"] | ||||||
| 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"] | ||||||
| 
 | 
 | ||||||
|  | # Error reports in yaml format | ||||||
|  | yaml = ["serde_yaml"] | ||||||
|  | 
 | ||||||
| [dependencies] | [dependencies] | ||||||
| # quick-js = "0.4.1" | # quick-js = "0.4.1" | ||||||
| quick-js = { path = "../quickjs-rs" } | quick-js = { path = "../quickjs-rs" } | ||||||
|  | @ -26,10 +30,9 @@ reqwest = {version = "0.11.11", default-features = false, features = ["json", "g | ||||||
| tokio = {version = "1.20.0", features = ["macros", "fs", "process"]} | tokio = {version = "1.20.0", features = ["macros", "fs", "process"]} | ||||||
| serde = { version = "1.0", features = ["derive"] } | serde = { version = "1.0", features = ["derive"] } | ||||||
| serde_json = "1.0.82" | serde_json = "1.0.82" | ||||||
| serde_yaml = "0.9.11" | serde_yaml = {version = "0.9.11", optional = true} | ||||||
| serde_with = {version = "2.0.0", features = ["json"] } | serde_with = {version = "2.0.0", features = ["json"] } | ||||||
| rand = "0.8.5" | rand = "0.8.5" | ||||||
| async-trait = "0.1.56" |  | ||||||
| chrono = {version = "0.4.19", features = ["serde"]} | chrono = {version = "0.4.19", features = ["serde"]} | ||||||
| chronoutil = "0.2.3" | chronoutil = "0.2.3" | ||||||
| futures = "0.3.21" | futures = "0.3.21" | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ edition = "2021" | ||||||
| [dependencies] | [dependencies] | ||||||
| rustypipe = {path = "../", default_features = false, features = ["rustls-tls-native-roots"]} | rustypipe = {path = "../", default_features = false, features = ["rustls-tls-native-roots"]} | ||||||
| reqwest = {version = "0.11.11", default_features = false} | reqwest = {version = "0.11.11", default_features = false} | ||||||
| tokio = {version = "1.20.0", features = ["rt-multi-thread"]} | tokio = {version = "1.20.0", features = ["macros", "rt-multi-thread"]} | ||||||
| indicatif = "0.17.0" | indicatif = "0.17.0" | ||||||
| futures = "0.3.21" | futures = "0.3.21" | ||||||
| anyhow = "1.0" | anyhow = "1.0" | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ use futures::stream::{self, StreamExt}; | ||||||
| use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; | ||||||
| use reqwest::{Client, ClientBuilder}; | use reqwest::{Client, ClientBuilder}; | ||||||
| use rustypipe::{ | use rustypipe::{ | ||||||
|     client2::{ClientType, RustyPipe}, |     client::{ClientType, RustyPipe}, | ||||||
|     model::stream_filter::Filter, |     model::stream_filter::Filter, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -59,6 +59,7 @@ async fn download_single_video( | ||||||
| 
 | 
 | ||||||
|     let res = async { |     let res = async { | ||||||
|         let player_data = rp |         let player_data = rp | ||||||
|  |             .query() | ||||||
|             .get_player(video_id.as_str(), ClientType::TvHtml5Embed) |             .get_player(video_id.as_str(), ClientType::TvHtml5Embed) | ||||||
|             .await |             .await | ||||||
|             .context(format!( |             .context(format!( | ||||||
|  | @ -148,7 +149,7 @@ async fn download_playlist( | ||||||
|         .expect("unable to build the HTTP client"); |         .expect("unable to build the HTTP client"); | ||||||
| 
 | 
 | ||||||
|     let rp = RustyPipe::default(); |     let rp = RustyPipe::default(); | ||||||
|     let playlist = rp.get_playlist(id).await.unwrap(); |     let playlist = rp.query().get_playlist(id).await.unwrap(); | ||||||
| 
 | 
 | ||||||
|     // Indicatif setup
 |     // Indicatif setup
 | ||||||
|     let multi = MultiProgress::new(); |     let multi = MultiProgress::new(); | ||||||
|  |  | ||||||
							
								
								
									
										363
									
								
								src/cache.rs
									
										
									
									
									
								
							
							
						
						
									
										363
									
								
								src/cache.rs
									
										
									
									
									
								
							|  | @ -1,364 +1,57 @@ | ||||||
| use std::{ | use std::{ | ||||||
|     fs::File, |     fs, | ||||||
|     future::Future, |  | ||||||
|     io::BufReader, |  | ||||||
|     path::{Path, PathBuf}, |     path::{Path, PathBuf}, | ||||||
|     sync::Arc, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use anyhow::Result; | use log::error; | ||||||
| use chrono::{DateTime, Duration, Utc}; |  | ||||||
| use log::{error, info}; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use tokio::sync::Mutex; |  | ||||||
| 
 | 
 | ||||||
| #[derive(Default, Debug, Clone)] | pub trait CacheStorage { | ||||||
| pub struct Cache { |     fn write(&self, data: &str); | ||||||
|     file: Option<PathBuf>, |     fn read(&self) -> Option<String>; | ||||||
|     data: Arc<Mutex<CacheData>>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Default, Debug, Clone, Serialize, Deserialize)] | pub struct FileStorage { | ||||||
| struct CacheData { |     path: PathBuf, | ||||||
|     desktop_client: Option<CacheEntry<ClientData>>, |  | ||||||
|     music_client: Option<CacheEntry<ClientData>>, |  | ||||||
|     deobf: Option<CacheEntry<DeobfData>>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | impl FileStorage { | ||||||
| struct CacheEntry<T> { |     pub fn new<P: AsRef<Path>>(path: P) -> Self { | ||||||
|     last_update: DateTime<Utc>, |  | ||||||
|     data: T, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl<T> From<T> for CacheEntry<T> { |  | ||||||
|     fn from(f: T) -> Self { |  | ||||||
|         Self { |         Self { | ||||||
|             last_update: Utc::now(), |             path: path.as_ref().to_path_buf(), | ||||||
|             data: f, |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | impl Default for FileStorage { | ||||||
| pub struct ClientData { |     fn default() -> Self { | ||||||
|     pub version: String, |         Self { | ||||||
|  |             path: Path::new("rustypipe_cache.json").into(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | impl CacheStorage for FileStorage { | ||||||
| pub struct DeobfData { |     fn write(&self, data: &str) { | ||||||
|     pub js_url: String, |         fs::write(&self.path, data).unwrap_or_else(|e| { | ||||||
|     pub sig_fn: String, |  | ||||||
|     pub nsig_fn: String, |  | ||||||
|     pub sts: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl Cache { |  | ||||||
|     pub async fn get_desktop_client_data<F>(&self, updater: F) -> Result<ClientData> |  | ||||||
|     where |  | ||||||
|         F: Future<Output = Result<ClientData>> + Send + 'static, |  | ||||||
|     { |  | ||||||
|         let mut cache = self.data.lock().await; |  | ||||||
| 
 |  | ||||||
|         if cache.desktop_client.is_none() |  | ||||||
|             || cache.desktop_client.as_ref().unwrap().last_update < Utc::now() - Duration::hours(24) |  | ||||||
|         { |  | ||||||
|             let cdata = updater.await?; |  | ||||||
|             cache.desktop_client = Some(CacheEntry::from(cdata.clone())); |  | ||||||
|             self.save(&cache); |  | ||||||
|             Ok(cdata) |  | ||||||
|         } else { |  | ||||||
|             Ok(cache.desktop_client.as_ref().unwrap().data.clone()) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn get_music_client_data<F>(&self, updater: F) -> Result<ClientData> |  | ||||||
|     where |  | ||||||
|         F: Future<Output = Result<ClientData>> + Send + 'static, |  | ||||||
|     { |  | ||||||
|         let mut cache = self.data.lock().await; |  | ||||||
| 
 |  | ||||||
|         if cache.music_client.is_none() |  | ||||||
|             || cache.music_client.as_ref().unwrap().last_update < Utc::now() - Duration::hours(24) |  | ||||||
|         { |  | ||||||
|             let cdata = updater.await?; |  | ||||||
|             cache.music_client = Some(CacheEntry::from(cdata.clone())); |  | ||||||
|             self.save(&cache); |  | ||||||
|             Ok(cdata) |  | ||||||
|         } else { |  | ||||||
|             Ok(cache.music_client.as_ref().unwrap().data.clone()) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn get_deobf_data<F>(&self, updater: F) -> Result<DeobfData> |  | ||||||
|     where |  | ||||||
|         F: Future<Output = Result<DeobfData>> + Send + 'static, |  | ||||||
|     { |  | ||||||
|         let mut cache = self.data.lock().await; |  | ||||||
|         if cache.deobf.is_none() |  | ||||||
|             || cache.deobf.as_ref().unwrap().last_update < Utc::now() - Duration::hours(24) |  | ||||||
|         { |  | ||||||
|             let deobf_data = updater.await?; |  | ||||||
|             cache.deobf = Some(CacheEntry::from(deobf_data.clone())); |  | ||||||
|             self.save(&cache); |  | ||||||
|             Ok(deobf_data) |  | ||||||
|         } else { |  | ||||||
|             Ok(cache.deobf.as_ref().unwrap().data.clone()) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn to_json(&self) -> Result<String> { |  | ||||||
|         let cache = self.data.lock().await; |  | ||||||
|         Ok(serde_json::to_string(&cache.clone())?) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn to_json_file<P: AsRef<Path>>(&self, path: P) -> Result<()> { |  | ||||||
|         let cache = self.data.lock().await; |  | ||||||
|         Ok(serde_json::to_writer(&File::create(path)?, &cache.clone())?) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn from_json(json: &str) -> Self { |  | ||||||
|         let data: CacheData = match serde_json::from_str(json) { |  | ||||||
|             Ok(cd) => cd, |  | ||||||
|             Err(e) => { |  | ||||||
|             error!( |             error!( | ||||||
|                     "Could not load cache from json, falling back to default. Error: {}", |                 "Could not write cache to file `{}`. Error: {}", | ||||||
|  |                 self.path.to_string_lossy(), | ||||||
|                 e |                 e | ||||||
|             ); |             ); | ||||||
|                 CacheData::default() |         }); | ||||||
|             } |  | ||||||
|         }; |  | ||||||
|         Cache { |  | ||||||
|             data: Arc::new(Mutex::new(data)), |  | ||||||
|             file: None, |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn from_json_file<P: AsRef<Path>>(path: P) -> Self { |     fn read(&self) -> Option<String> { | ||||||
|         let file = match File::open(path.as_ref()) { |         match fs::read_to_string(&self.path) { | ||||||
|             Ok(file) => file, |             Ok(data) => Some(data), | ||||||
|             Err(e) => { |  | ||||||
|                 if e.kind() == std::io::ErrorKind::NotFound { |  | ||||||
|                     info!( |  | ||||||
|                         "Cache json file at {} not found, will be created", |  | ||||||
|                         path.as_ref().to_string_lossy() |  | ||||||
|                     ) |  | ||||||
|                 } else { |  | ||||||
|                     error!( |  | ||||||
|                         "Could not open cache json file, falling back to default. Error: {}", |  | ||||||
|                         e |  | ||||||
|                     ); |  | ||||||
|                 } |  | ||||||
|                 return Cache { |  | ||||||
|                     file: Some(path.as_ref().to_path_buf()), |  | ||||||
|                     ..Default::default() |  | ||||||
|                 }; |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
|         let data: CacheData = match serde_json::from_reader(BufReader::new(file)) { |  | ||||||
|             Ok(data) => data, |  | ||||||
|             Err(e) => { |             Err(e) => { | ||||||
|                 error!( |                 error!( | ||||||
|                     "Could not load cache from json, falling back to default. Error: {}", |                     "Could not load cache from file `{}`. Error: {}", | ||||||
|  |                     self.path.to_string_lossy(), | ||||||
|                     e |                     e | ||||||
|                 ); |                 ); | ||||||
|                 return Cache { |                 None | ||||||
|                     file: Some(path.as_ref().to_path_buf()), |  | ||||||
|                     ..Default::default() |  | ||||||
|                 }; |  | ||||||
|             } |             } | ||||||
|         }; |  | ||||||
|         Cache { |  | ||||||
|             data: Arc::new(Mutex::new(data)), |  | ||||||
|             file: Some(path.as_ref().to_path_buf()), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     fn save(&self, cache: &CacheData) { |  | ||||||
|         match self.file.as_ref() { |  | ||||||
|             Some(file) => match File::create(file) { |  | ||||||
|                 Ok(file) => match serde_json::to_writer(file, cache) { |  | ||||||
|                     Ok(_) => {} |  | ||||||
|                     Err(e) => error!("Could not write cache to json. Error: {}", e), |  | ||||||
|                 }, |  | ||||||
|                 Err(e) => error!("Could not open cache json file. Error: {}", e), |  | ||||||
|             }, |  | ||||||
|             None => {} |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
|     use temp_testdir::TempDir; |  | ||||||
| 
 |  | ||||||
|     use super::*; |  | ||||||
| 
 |  | ||||||
|     #[tokio::test] |  | ||||||
|     async fn test() { |  | ||||||
|         let cache = Cache::default(); |  | ||||||
| 
 |  | ||||||
|         let desktop_c = cache |  | ||||||
|             .get_desktop_client_data(async { |  | ||||||
|                 Ok(ClientData { |  | ||||||
|                     version: "1.2.3".to_owned(), |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             desktop_c, |  | ||||||
|             ClientData { |  | ||||||
|                 version: "1.2.3".to_owned() |  | ||||||
|             } |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         let music_c = cache |  | ||||||
|             .get_music_client_data(async { |  | ||||||
|                 Ok(ClientData { |  | ||||||
|                     version: "4.5.6".to_owned(), |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             music_c, |  | ||||||
|             ClientData { |  | ||||||
|                 version: "4.5.6".to_owned() |  | ||||||
|             } |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         let deobf_data = cache |  | ||||||
|             .get_deobf_data(async { |  | ||||||
|                 Ok(DeobfData { |  | ||||||
|                     js_url: |  | ||||||
|                         "https://www.youtube.com/s/player/011af516/player_ias.vflset/en_US/base.js" |  | ||||||
|                             .to_owned(), |  | ||||||
|                     sig_fn: "t_sig_fn".to_owned(), |  | ||||||
|                     nsig_fn: "t_nsig_fn".to_owned(), |  | ||||||
|                     sts: "t_sts".to_owned(), |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             deobf_data, |  | ||||||
|             DeobfData { |  | ||||||
|                 js_url: "https://www.youtube.com/s/player/011af516/player_ias.vflset/en_US/base.js" |  | ||||||
|                     .to_owned(), |  | ||||||
|                 sig_fn: "t_sig_fn".to_owned(), |  | ||||||
|                 nsig_fn: "t_nsig_fn".to_owned(), |  | ||||||
|                 sts: "t_sts".to_owned(), |  | ||||||
|             } |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         // Create a new cache from the first one's json
 |  | ||||||
|         // and check if it returns the same cached data
 |  | ||||||
|         let json = cache.to_json().await.unwrap(); |  | ||||||
|         let new_cache = Cache::from_json(&json); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             new_cache |  | ||||||
|                 .get_desktop_client_data(async { |  | ||||||
|                     Ok(ClientData { |  | ||||||
|                         version: "".to_owned(), |  | ||||||
|                     }) |  | ||||||
|                 }) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(), |  | ||||||
|             desktop_c |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             new_cache |  | ||||||
|                 .get_music_client_data(async { |  | ||||||
|                     Ok(ClientData { |  | ||||||
|                         version: "".to_owned(), |  | ||||||
|                     }) |  | ||||||
|                 }) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(), |  | ||||||
|             music_c |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             new_cache |  | ||||||
|                 .get_deobf_data(async { |  | ||||||
|                     Ok(DeobfData { |  | ||||||
|                         js_url: "".to_owned(), |  | ||||||
|                         nsig_fn: "".to_owned(), |  | ||||||
|                         sig_fn: "".to_owned(), |  | ||||||
|                         sts: "".to_owned(), |  | ||||||
|                     }) |  | ||||||
|                 }) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(), |  | ||||||
|             deobf_data |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[tokio::test] |  | ||||||
|     async fn test_file() { |  | ||||||
|         let temp = TempDir::default(); |  | ||||||
|         let mut file_path = PathBuf::from(temp.as_ref()); |  | ||||||
|         file_path.push("cache.json"); |  | ||||||
| 
 |  | ||||||
|         let cache = Cache::from_json_file(file_path.clone()); |  | ||||||
| 
 |  | ||||||
|         let cdata = cache |  | ||||||
|             .get_desktop_client_data(async { |  | ||||||
|                 Ok(ClientData { |  | ||||||
|                     version: "1.2.3".to_owned(), |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         let deobf_data = cache |  | ||||||
|             .get_deobf_data(async { |  | ||||||
|                 Ok(DeobfData { |  | ||||||
|                     js_url: |  | ||||||
|                         "https://www.youtube.com/s/player/011af516/player_ias.vflset/en_US/base.js" |  | ||||||
|                             .to_owned(), |  | ||||||
|                     sig_fn: "t_sig_fn".to_owned(), |  | ||||||
|                     nsig_fn: "t_nsig_fn".to_owned(), |  | ||||||
|                     sts: "t_sts".to_owned(), |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert!(file_path.exists()); |  | ||||||
|         let new_cache = Cache::from_json_file(file_path.clone()); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             new_cache |  | ||||||
|                 .get_desktop_client_data(async { |  | ||||||
|                     Ok(ClientData { |  | ||||||
|                         version: "".to_owned(), |  | ||||||
|                     }) |  | ||||||
|                 }) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(), |  | ||||||
|             cdata |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         assert_eq!( |  | ||||||
|             new_cache |  | ||||||
|                 .get_deobf_data(async { |  | ||||||
|                     Ok(DeobfData { |  | ||||||
|                         js_url: "".to_owned(), |  | ||||||
|                         nsig_fn: "".to_owned(), |  | ||||||
|                         sig_fn: "".to_owned(), |  | ||||||
|                         sts: "".to_owned(), |  | ||||||
|                     }) |  | ||||||
|                 }) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(), |  | ||||||
|             deobf_data |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,41 +0,0 @@ | ||||||
| use anyhow::Result; |  | ||||||
| use reqwest::Method; |  | ||||||
| use serde::Serialize; |  | ||||||
| 
 |  | ||||||
| use super::{response, ClientType, ContextYT, RustyTube}; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct QChannel { |  | ||||||
|     context: ContextYT, |  | ||||||
|     browse_id: String, |  | ||||||
|     params: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl RustyTube { |  | ||||||
|     async fn get_channel_response(&self, channel_id: &str) -> Result<response::Channel> { |  | ||||||
|         let client = self.get_ytclient(ClientType::Desktop); |  | ||||||
|         let context = client.get_context(true).await; |  | ||||||
| 
 |  | ||||||
|         let request_body = QChannel { |  | ||||||
|             context, |  | ||||||
|             browse_id: channel_id.to_owned(), |  | ||||||
|             params: "EgZ2aWRlb3PyBgQKAjoA".to_owned(), |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let resp = client |  | ||||||
|             .request_builder(Method::POST, "browse") |  | ||||||
|             .await |  | ||||||
|             .json(&request_body) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status()?; |  | ||||||
| 
 |  | ||||||
|         Ok(resp.json::<response::Channel>().await?) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
							
								
								
									
										1194
									
								
								src/client/mod.rs
									
										
									
									
									
								
							
							
						
						
									
										1194
									
								
								src/client/mod.rs
									
										
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -1,20 +1,28 @@ | ||||||
| use std::{ | use std::{ | ||||||
|     borrow::Cow, |     borrow::Cow, | ||||||
|     collections::{BTreeMap, HashMap}, |     collections::{BTreeMap, HashMap}, | ||||||
|     sync::Arc, |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use anyhow::{anyhow, bail, Result}; | use anyhow::{anyhow, bail, Result}; | ||||||
| use chrono::{Local, NaiveDateTime, NaiveTime, TimeZone}; | use chrono::{Local, NaiveDateTime, NaiveTime, TimeZone}; | ||||||
| use fancy_regex::Regex; | use fancy_regex::Regex; | ||||||
| use log::{error, warn}; |  | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use reqwest::Method; | use reqwest::{Method, Url}; | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| use url::Url; |  | ||||||
| 
 | 
 | ||||||
| use super::{response, ClientType, ContextYT, RustyTube, YTClient}; | use crate::{ | ||||||
| use crate::{client::response::player, deobfuscate::Deobfuscator, model::*, util}; |     deobfuscate::Deobfuscator, | ||||||
|  |     model::{ | ||||||
|  |         AudioCodec, AudioFormat, AudioStream, AudioTrack, Channel, Language, Subtitle, VideoCodec, | ||||||
|  |         VideoFormat, VideoInfo, VideoPlayer, VideoStream, | ||||||
|  |     }, | ||||||
|  |     util, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | use super::{ | ||||||
|  |     response::{self, player}, | ||||||
|  |     ClientType, ContextYT, MapResponse, MapResult, RustyPipeQuery, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Debug, Serialize)] | #[derive(Clone, Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -49,36 +57,18 @@ struct QContentPlaybackContext { | ||||||
|     referer: String, |     referer: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl RustyTube { | impl RustyPipeQuery { | ||||||
|     pub async fn get_player(&self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> { |     pub async fn get_player(self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> { | ||||||
|         let client = self.get_ytclient(client_type); |         let q1 = self.clone(); | ||||||
|         let (context, deobf) = tokio::join!( |         let t_context = tokio::spawn(async move { q1.get_context(client_type, false).await }); | ||||||
|             client.get_context(false), |         let q2 = self.clone(); | ||||||
|             Deobfuscator::from_fetched_info(client.http_client(), self.cache.clone()) |         let t_deobf = tokio::spawn(async move { q2.get_deobf().await }); | ||||||
|         ); |  | ||||||
|         let deobf = deobf?; |  | ||||||
|         let request_body = build_request_body(client.clone(), &deobf, context, video_id); |  | ||||||
| 
 | 
 | ||||||
|         let resp = client |         let (context, deobf) = tokio::join!(t_context, t_deobf); | ||||||
|             .request_builder(Method::POST, "player") |         let context = context.unwrap(); | ||||||
|             .await |         let deobf = deobf.unwrap()?; | ||||||
|             .json(&request_body) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status()?; |  | ||||||
| 
 | 
 | ||||||
|         let player_response = resp.json::<response::Player>().await?; |         let request_body = if client_type.is_web() { | ||||||
|         map_player_data(player_response, &deobf) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn build_request_body( |  | ||||||
|     client: Arc<dyn YTClient>, |  | ||||||
|     deobf: &Deobfuscator, |  | ||||||
|     context: ContextYT, |  | ||||||
|     video_id: &str, |  | ||||||
| ) -> QPlayer { |  | ||||||
|     if client.get_type().is_web() { |  | ||||||
|             QPlayer { |             QPlayer { | ||||||
|                 context, |                 context, | ||||||
|                 playback_context: Some(QPlaybackContext { |                 playback_context: Some(QPlaybackContext { | ||||||
|  | @ -101,6 +91,177 @@ fn build_request_body( | ||||||
|                 content_check_ok: true, |                 content_check_ok: true, | ||||||
|                 racy_check_ok: true, |                 racy_check_ok: true, | ||||||
|             } |             } | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         self.execute_request_deobf::<response::Player, _, _>( | ||||||
|  |             client_type, | ||||||
|  |             "get_player", | ||||||
|  |             Method::POST, | ||||||
|  |             "player", | ||||||
|  |             video_id, | ||||||
|  |             &request_body, | ||||||
|  |             Some(&deobf), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl MapResponse<VideoPlayer> for response::Player { | ||||||
|  |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         id: &str, | ||||||
|  |         _lang: Language, | ||||||
|  |         deobf: Option<&Deobfuscator>, | ||||||
|  |     ) -> Result<super::MapResult<VideoPlayer>> { | ||||||
|  |         let deobf = deobf.unwrap(); | ||||||
|  |         let mut warnings = vec![]; | ||||||
|  | 
 | ||||||
|  |         // Check playability status
 | ||||||
|  |         match self.playability_status { | ||||||
|  |             response::player::PlayabilityStatus::Ok { live_streamability } => { | ||||||
|  |                 if live_streamability.is_some() { | ||||||
|  |                     bail!("Active livestreams are not supported") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             response::player::PlayabilityStatus::Unplayable { reason } => { | ||||||
|  |                 bail!("Video is unplayable. Reason: {}", reason) | ||||||
|  |             } | ||||||
|  |             response::player::PlayabilityStatus::LoginRequired { reason } => { | ||||||
|  |                 bail!("Playback requires login. Reason: {}", reason) | ||||||
|  |             } | ||||||
|  |             response::player::PlayabilityStatus::LiveStreamOffline { reason } => { | ||||||
|  |                 bail!("Livestream is offline. Reason: {}", reason) | ||||||
|  |             } | ||||||
|  |             response::player::PlayabilityStatus::Error { reason } => { | ||||||
|  |                 bail!("Video was deleted. Reason: {}", reason) | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         let mut streaming_data = some_or_bail!( | ||||||
|  |             self.streaming_data, | ||||||
|  |             Err(anyhow!("No streaming data was returned")) | ||||||
|  |         ); | ||||||
|  |         let video_details = some_or_bail!( | ||||||
|  |             self.video_details, | ||||||
|  |             Err(anyhow!("No video details were returned")) | ||||||
|  |         ); | ||||||
|  |         let microformat = self.microformat.map(|m| m.player_microformat_renderer); | ||||||
|  |         let (publish_date, category, tags, is_family_safe) = | ||||||
|  |             microformat.map_or((None, None, None, None), |m| { | ||||||
|  |                 ( | ||||||
|  |                     Local | ||||||
|  |                         .from_local_datetime(&NaiveDateTime::new( | ||||||
|  |                             m.publish_date, | ||||||
|  |                             NaiveTime::from_hms(0, 0, 0), | ||||||
|  |                         )) | ||||||
|  |                         .single(), | ||||||
|  |                     Some(m.category), | ||||||
|  |                     m.tags, | ||||||
|  |                     Some(m.is_family_safe), | ||||||
|  |                 ) | ||||||
|  |             }); | ||||||
|  | 
 | ||||||
|  |         if video_details.video_id != id { | ||||||
|  |             bail!( | ||||||
|  |                 "got wrong video id {}, expected {}", | ||||||
|  |                 video_details.video_id, | ||||||
|  |                 id | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         let video_info = VideoInfo { | ||||||
|  |             id: video_details.video_id, | ||||||
|  |             title: video_details.title, | ||||||
|  |             description: video_details.short_description, | ||||||
|  |             length: video_details.length_seconds, | ||||||
|  |             thumbnails: video_details.thumbnail.unwrap_or_default().into(), | ||||||
|  |             channel: Channel { | ||||||
|  |                 id: video_details.channel_id, | ||||||
|  |                 name: video_details.author, | ||||||
|  |             }, | ||||||
|  |             publish_date, | ||||||
|  |             view_count: video_details.view_count, | ||||||
|  |             keywords: match video_details.keywords { | ||||||
|  |                 Some(keywords) => keywords, | ||||||
|  |                 None => tags.unwrap_or_default(), | ||||||
|  |             }, | ||||||
|  |             category, | ||||||
|  |             is_live_content: video_details.is_live_content, | ||||||
|  |             is_family_safe, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         let mut formats = streaming_data.formats.c; | ||||||
|  |         formats.append(&mut streaming_data.adaptive_formats.c); | ||||||
|  | 
 | ||||||
|  |         warnings.append(&mut streaming_data.formats.warnings); | ||||||
|  |         warnings.append(&mut streaming_data.adaptive_formats.warnings); | ||||||
|  | 
 | ||||||
|  |         let mut last_nsig: [String; 2] = ["".to_owned(), "".to_owned()]; | ||||||
|  | 
 | ||||||
|  |         let mut video_streams: Vec<VideoStream> = Vec::new(); | ||||||
|  |         let mut video_only_streams: Vec<VideoStream> = Vec::new(); | ||||||
|  |         let mut audio_streams: Vec<AudioStream> = Vec::new(); | ||||||
|  | 
 | ||||||
|  |         for f in formats { | ||||||
|  |             if f.format_type == player::FormatType::FormatStreamTypeOtf { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             match (f.is_video(), f.is_audio()) { | ||||||
|  |                 (true, true) => { | ||||||
|  |                     let mut map_res = map_video_stream(f, deobf, &mut last_nsig); | ||||||
|  |                     warnings.append(&mut map_res.warnings); | ||||||
|  |                     if let Some(c) = map_res.c { | ||||||
|  |                         video_streams.push(c); | ||||||
|  |                     }; | ||||||
|  |                 } | ||||||
|  |                 (true, false) => { | ||||||
|  |                     let mut map_res = map_video_stream(f, deobf, &mut last_nsig); | ||||||
|  |                     warnings.append(&mut map_res.warnings); | ||||||
|  |                     if let Some(c) = map_res.c { | ||||||
|  |                         video_only_streams.push(c); | ||||||
|  |                     }; | ||||||
|  |                 } | ||||||
|  |                 (false, true) => { | ||||||
|  |                     let mut map_res = map_audio_stream(f, deobf, &mut last_nsig); | ||||||
|  |                     warnings.append(&mut map_res.warnings); | ||||||
|  |                     if let Some(c) = map_res.c { | ||||||
|  |                         audio_streams.push(c); | ||||||
|  |                     }; | ||||||
|  |                 } | ||||||
|  |                 (false, false) => warnings.push(format!("invalid stream: itag {}", f.itag)), | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         video_streams.sort(); | ||||||
|  |         video_only_streams.sort(); | ||||||
|  |         audio_streams.sort(); | ||||||
|  | 
 | ||||||
|  |         let mut subtitles = vec![]; | ||||||
|  |         if let Some(captions) = self.captions { | ||||||
|  |             for c in captions.player_captions_tracklist_renderer.caption_tracks { | ||||||
|  |                 let lang_auto = c.name.strip_suffix(" (auto-generated)"); | ||||||
|  | 
 | ||||||
|  |                 subtitles.push(Subtitle { | ||||||
|  |                     url: c.base_url, | ||||||
|  |                     lang: c.language_code, | ||||||
|  |                     lang_name: lang_auto.unwrap_or(&c.name).to_owned(), | ||||||
|  |                     auto_generated: lang_auto.is_some(), | ||||||
|  |                 }) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         Ok(MapResult { | ||||||
|  |             c: VideoPlayer { | ||||||
|  |                 info: video_info, | ||||||
|  |                 video_streams, | ||||||
|  |                 video_only_streams, | ||||||
|  |                 audio_streams, | ||||||
|  |                 subtitles, | ||||||
|  |                 expires_in_seconds: streaming_data.expires_in_seconds, | ||||||
|  |             }, | ||||||
|  |             warnings, | ||||||
|  |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -136,7 +297,7 @@ fn deobf_nsig( | ||||||
|     let nsig: String; |     let nsig: String; | ||||||
|     match url_params.get("n") { |     match url_params.get("n") { | ||||||
|         Some(n) => { |         Some(n) => { | ||||||
|             nsig = if n.to_owned() == last_nsig[0] { |             nsig = if n == &last_nsig[0] { | ||||||
|                 last_nsig[1].to_owned() |                 last_nsig[1].to_owned() | ||||||
|             } else { |             } else { | ||||||
|                 let nsig = deobf.deobfuscate_nsig(n)?; |                 let nsig = deobf.deobfuscate_nsig(n)?; | ||||||
|  | @ -157,108 +318,192 @@ fn map_url( | ||||||
|     signature_cipher: &Option<String>, |     signature_cipher: &Option<String>, | ||||||
|     deobf: &Deobfuscator, |     deobf: &Deobfuscator, | ||||||
|     last_nsig: &mut [String; 2], |     last_nsig: &mut [String; 2], | ||||||
| ) -> Option<(String, bool)> { | ) -> MapResult<Option<(String, bool)>> { | ||||||
|     let (url_base, mut url_params) = match url { |     let (url_base, mut url_params) = match url { | ||||||
|         Some(url) => ok_or_bail!(util::url_to_params(url), None), |         Some(url) => ok_or_bail!( | ||||||
|  |             util::url_to_params(url), | ||||||
|  |             MapResult { | ||||||
|  |                 c: None, | ||||||
|  |                 warnings: vec![format!("Could not parse url `{}`", url)] | ||||||
|  |             } | ||||||
|  |         ), | ||||||
|         None => match signature_cipher { |         None => match signature_cipher { | ||||||
|             Some(signature_cipher) => match cipher_to_url_params(signature_cipher, deobf) { |             Some(signature_cipher) => match cipher_to_url_params(signature_cipher, deobf) { | ||||||
|                 Ok(res) => res, |                 Ok(res) => res, | ||||||
|                 Err(e) => { |                 Err(e) => { | ||||||
|                     error!("Could not deobfuscate signatureCipher: {}", e); |                     return MapResult { | ||||||
|                     return None; |                         c: None, | ||||||
|  |                         warnings: vec![format!( | ||||||
|  |                             "Could not deobfuscate signatureCipher `{}`: {}", | ||||||
|  |                             signature_cipher, e | ||||||
|  |                         )], | ||||||
|  |                     }; | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|             None => return None, |             None => { | ||||||
|  |                 return MapResult { | ||||||
|  |                     c: None, | ||||||
|  |                     warnings: vec!["stream contained neither url nor cipher".to_owned()], | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         }, |         }, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|  |     let mut warnings = vec![]; | ||||||
|     let mut throttled = false; |     let mut throttled = false; | ||||||
|     deobf_nsig(&mut url_params, deobf, last_nsig).unwrap_or_else(|e| { |     deobf_nsig(&mut url_params, deobf, last_nsig).unwrap_or_else(|e| { | ||||||
|         warn!("Could not deobfuscate nsig: {}", e); |         warnings.push(format!( | ||||||
|  |             "Could not deobfuscate nsig (params: {:?}): {}", | ||||||
|  |             url_params, e | ||||||
|  |         )); | ||||||
|         throttled = true; |         throttled = true; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     Some(( |     MapResult { | ||||||
|  |         c: Some(( | ||||||
|             ok_or_bail!( |             ok_or_bail!( | ||||||
|                 Url::parse_with_params(url_base.as_str(), url_params.iter()), |                 Url::parse_with_params(url_base.as_str(), url_params.iter()), | ||||||
|             None |                 MapResult { | ||||||
|  |                     c: None, | ||||||
|  |                     warnings: vec![format!( | ||||||
|  |                         "url could not be joined. url: `{}` params: {:?}", | ||||||
|  |                         url_base, url_params | ||||||
|  |                     )], | ||||||
|  |                 } | ||||||
|             ) |             ) | ||||||
|             .to_string(), |             .to_string(), | ||||||
|             throttled, |             throttled, | ||||||
|     )) |         )), | ||||||
|  |         warnings, | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_video_stream( | fn map_video_stream( | ||||||
|     f: &player::Format, |     f: player::Format, | ||||||
|     deobf: &Deobfuscator, |     deobf: &Deobfuscator, | ||||||
|     last_nsig: &mut [String; 2], |     last_nsig: &mut [String; 2], | ||||||
| ) -> Option<VideoStream> { | ) -> MapResult<Option<VideoStream>> { | ||||||
|     let (mtype, codecs) = some_or_bail!(parse_mime(&f.mime_type), None); |     let (mtype, codecs) = some_or_bail!( | ||||||
|     let (url, throttled) = |         parse_mime(&f.mime_type), | ||||||
|         some_or_bail!(map_url(&f.url, &f.signature_cipher, deobf, last_nsig), None); |         MapResult { | ||||||
|  |             c: None, | ||||||
|  |             warnings: vec![format!( | ||||||
|  |                 "Invalid mime type `{}` in video format {:?}", | ||||||
|  |                 &f.mime_type, &f | ||||||
|  |             )] | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  |     let map_res = map_url(&f.url, &f.signature_cipher, deobf, last_nsig); | ||||||
| 
 | 
 | ||||||
|     Some(VideoStream { |     match map_res.c { | ||||||
|  |         Some((url, throttled)) => MapResult { | ||||||
|  |             c: Some(VideoStream { | ||||||
|                 url, |                 url, | ||||||
|                 itag: f.itag, |                 itag: f.itag, | ||||||
|                 bitrate: f.bitrate, |                 bitrate: f.bitrate, | ||||||
|         average_bitrate: f.average_bitrate, |                 average_bitrate: f.average_bitrate.unwrap_or(f.bitrate), | ||||||
|                 size: f.content_length, |                 size: f.content_length, | ||||||
|         index_range: f.index_range.clone(), |                 index_range: f.index_range, | ||||||
|         init_range: f.init_range.clone(), |                 init_range: f.init_range, | ||||||
|         width: some_or_bail!(f.width, None), |                 // Note that the format has already been verified using
 | ||||||
|         height: some_or_bail!(f.height, None), |                 // is_video(), so these unwraps are safe
 | ||||||
|         fps: some_or_bail!(f.fps, None), |                 width: f.width.unwrap(), | ||||||
|         quality: some_or_bail!(f.quality_label.clone(), None), |                 height: f.height.unwrap(), | ||||||
|         hdr: f.color_info.clone().unwrap_or_default().primaries |                 fps: f.fps.unwrap(), | ||||||
|  |                 quality: f.quality_label.unwrap(), | ||||||
|  |                 hdr: f.color_info.unwrap_or_default().primaries | ||||||
|                     == player::Primaries::ColorPrimariesBt2020, |                     == player::Primaries::ColorPrimariesBt2020, | ||||||
|                 mime: f.mime_type.to_owned(), |                 mime: f.mime_type.to_owned(), | ||||||
|         format: some_or_bail!(get_video_format(mtype), None), |                 format: some_or_bail!( | ||||||
|  |                     get_video_format(mtype), | ||||||
|  |                     MapResult { | ||||||
|  |                         c: None, | ||||||
|  |                         warnings: vec![format!("invalid video format. itag: {}", f.itag)] | ||||||
|  |                     } | ||||||
|  |                 ), | ||||||
|                 codec: get_video_codec(codecs), |                 codec: get_video_codec(codecs), | ||||||
|                 throttled, |                 throttled, | ||||||
|     }) |             }), | ||||||
|  |             warnings: map_res.warnings, | ||||||
|  |         }, | ||||||
|  |         None => MapResult { | ||||||
|  |             c: None, | ||||||
|  |             warnings: map_res.warnings, | ||||||
|  |         }, | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_audio_stream( | fn map_audio_stream( | ||||||
|     f: &player::Format, |     f: player::Format, | ||||||
|     deobf: &Deobfuscator, |     deobf: &Deobfuscator, | ||||||
|     last_nsig: &mut [String; 2], |     last_nsig: &mut [String; 2], | ||||||
| ) -> Option<AudioStream> { | ) -> MapResult<Option<AudioStream>> { | ||||||
|     static LANG_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r#"^([a-z]{2})\."#).unwrap()); |     static LANG_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r#"^([a-z]{2})\."#).unwrap()); | ||||||
| 
 | 
 | ||||||
|     let (mtype, codecs) = some_or_bail!(parse_mime(&f.mime_type), None); |     let (mtype, codecs) = some_or_bail!( | ||||||
|     let (url, throttled) = |         parse_mime(&f.mime_type), | ||||||
|         some_or_bail!(map_url(&f.url, &f.signature_cipher, deobf, last_nsig), None); |         MapResult { | ||||||
|  |             c: None, | ||||||
|  |             warnings: vec![format!( | ||||||
|  |                 "Invalid mime type `{}` in video format {:?}", | ||||||
|  |                 &f.mime_type, &f | ||||||
|  |             )] | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  |     let map_res = map_url(&f.url, &f.signature_cipher, deobf, last_nsig); | ||||||
| 
 | 
 | ||||||
|     Some(AudioStream { |     match map_res.c { | ||||||
|  |         Some((url, throttled)) => MapResult { | ||||||
|  |             c: Some(AudioStream { | ||||||
|                 url, |                 url, | ||||||
|                 itag: f.itag, |                 itag: f.itag, | ||||||
|                 bitrate: f.bitrate, |                 bitrate: f.bitrate, | ||||||
|         average_bitrate: f.average_bitrate, |                 average_bitrate: f.average_bitrate.unwrap_or(f.bitrate), | ||||||
|         size: f.content_length, |                 size: f.content_length.unwrap(), | ||||||
|         index_range: f.index_range.to_owned(), |                 index_range: f.index_range, | ||||||
|         init_range: f.init_range.to_owned(), |                 init_range: f.init_range, | ||||||
|                 mime: f.mime_type.to_owned(), |                 mime: f.mime_type.to_owned(), | ||||||
|         format: some_or_bail!(get_audio_format(mtype), None), |                 format: some_or_bail!( | ||||||
|  |                     get_audio_format(mtype), | ||||||
|  |                     MapResult { | ||||||
|  |                         c: None, | ||||||
|  |                         warnings: vec![format!("invalid audio format. itag: {}", f.itag)] | ||||||
|  |                     } | ||||||
|  |                 ), | ||||||
|                 codec: get_audio_codec(codecs), |                 codec: get_audio_codec(codecs), | ||||||
|                 throttled, |                 throttled, | ||||||
|         track: f.audio_track.as_ref().map(|t| AudioTrack { |                 track: match f.audio_track { | ||||||
|             id: t.id.to_owned(), |                     Some(t) => { | ||||||
|             lang: LANG_PATTERN |                         let lang = LANG_PATTERN | ||||||
|                             .captures(&t.id) |                             .captures(&t.id) | ||||||
|                             .ok() |                             .ok() | ||||||
|                             .flatten() |                             .flatten() | ||||||
|                 .map(|m| m.get(1).unwrap().as_str().to_owned()), |                             .map(|m| m.get(1).unwrap().as_str().to_owned()); | ||||||
|             lang_name: t.display_name.to_owned(), | 
 | ||||||
|  |                         Some(AudioTrack { | ||||||
|  |                             id: t.id, | ||||||
|  |                             lang, | ||||||
|  |                             lang_name: t.display_name, | ||||||
|                             is_default: t.audio_is_default, |                             is_default: t.audio_is_default, | ||||||
|         }), |  | ||||||
|                         }) |                         }) | ||||||
|  |                     } | ||||||
|  |                     None => None, | ||||||
|  |                 }, | ||||||
|  |             }), | ||||||
|  |             warnings: map_res.warnings, | ||||||
|  |         }, | ||||||
|  |         None => MapResult { | ||||||
|  |             c: None, | ||||||
|  |             warnings: map_res.warnings, | ||||||
|  |         }, | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn parse_mime(mime: &str) -> Option<(&str, Vec<&str>)> { | fn parse_mime(mime: &str) -> Option<(&str, Vec<&str>)> { | ||||||
|     static PATTERN: Lazy<Regex> = |     static PATTERN: Lazy<Regex> = | ||||||
|         Lazy::new(|| Regex::new(r#"(\w+/\w+);\scodecs="([a-zA-Z-0-9.,\s]*)""#).unwrap()); |         Lazy::new(|| Regex::new(r#"(\w+/\w+);\scodecs="([a-zA-Z-0-9.,\s]*)""#).unwrap()); | ||||||
| 
 | 
 | ||||||
|     let captures = some_or_bail!(PATTERN.captures(&mime).ok().flatten(), None); |     let captures = some_or_bail!(PATTERN.captures(mime).ok().flatten(), None); | ||||||
|     Some(( |     Some(( | ||||||
|         captures.get(1).unwrap().as_str(), |         captures.get(1).unwrap().as_str(), | ||||||
|         captures |         captures | ||||||
|  | @ -313,140 +558,16 @@ fn get_audio_codec(codecs: Vec<&str>) -> AudioCodec { | ||||||
|     AudioCodec::Unknown |     AudioCodec::Unknown | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<VideoPlayer> { |  | ||||||
|     // Check playability status
 |  | ||||||
|     match response.playability_status { |  | ||||||
|         response::player::PlayabilityStatus::Ok { live_streamability } => { |  | ||||||
|             if live_streamability.is_some() { |  | ||||||
|                 bail!("Active livestreams are not supported") |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         response::player::PlayabilityStatus::Unplayable { reason } => { |  | ||||||
|             bail!("Video is unplayable. Reason: {}", reason) |  | ||||||
|         } |  | ||||||
|         response::player::PlayabilityStatus::LoginRequired { reason } => { |  | ||||||
|             bail!("Playback requires login. Reason: {}", reason) |  | ||||||
|         } |  | ||||||
|         response::player::PlayabilityStatus::LiveStreamOffline { reason } => { |  | ||||||
|             bail!("Livestream is offline. Reason: {}", reason) |  | ||||||
|         } |  | ||||||
|         response::player::PlayabilityStatus::Error { reason } => { |  | ||||||
|             bail!("Video was deleted. Reason: {}", reason) |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     let streaming_data = some_or_bail!( |  | ||||||
|         response.streaming_data, |  | ||||||
|         Err(anyhow!("No streaming data was returned")) |  | ||||||
|     ); |  | ||||||
|     let video_details = some_or_bail!( |  | ||||||
|         response.video_details, |  | ||||||
|         Err(anyhow!("No video details were returned")) |  | ||||||
|     ); |  | ||||||
|     let microformat = response.microformat.map(|m| m.player_microformat_renderer); |  | ||||||
| 
 |  | ||||||
|     let video_info = VideoInfo { |  | ||||||
|         id: video_details.video_id, |  | ||||||
|         title: video_details.title, |  | ||||||
|         description: video_details.short_description, |  | ||||||
|         length: video_details.length_seconds, |  | ||||||
|         thumbnails: video_details |  | ||||||
|             .thumbnail |  | ||||||
|             .unwrap_or_default() |  | ||||||
|             .thumbnails |  | ||||||
|             .iter() |  | ||||||
|             .map(|t| Thumbnail { |  | ||||||
|                 url: t.url.to_owned(), |  | ||||||
|                 height: t.height, |  | ||||||
|                 width: t.width, |  | ||||||
|             }) |  | ||||||
|             .collect(), |  | ||||||
|         channel: Channel { |  | ||||||
|             id: video_details.channel_id, |  | ||||||
|             name: video_details.author, |  | ||||||
|         }, |  | ||||||
|         publish_date: microformat.as_ref().map(|m| { |  | ||||||
|             let ndt = NaiveDateTime::new(m.publish_date, NaiveTime::from_hms(0, 0, 0)); |  | ||||||
|             Local.from_local_datetime(&ndt).unwrap() |  | ||||||
|         }), |  | ||||||
|         view_count: video_details.view_count, |  | ||||||
|         keywords: video_details |  | ||||||
|             .keywords |  | ||||||
|             .or_else(|| microformat.as_ref().map_or(None, |mf| mf.tags.clone())) |  | ||||||
|             .unwrap_or_default(), |  | ||||||
|         category: microformat.as_ref().map(|m| m.category.to_owned()), |  | ||||||
|         is_live_content: video_details.is_live_content, |  | ||||||
|         is_family_safe: microformat.as_ref().map(|m| m.is_family_safe), |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     let mut formats = streaming_data.formats.clone(); |  | ||||||
|     formats.append(&mut streaming_data.adaptive_formats.clone()); |  | ||||||
| 
 |  | ||||||
|     let mut last_nsig: [String; 2] = ["".to_owned(), "".to_owned()]; |  | ||||||
| 
 |  | ||||||
|     let mut video_streams: Vec<VideoStream> = Vec::new(); |  | ||||||
|     let mut video_only_streams: Vec<VideoStream> = Vec::new(); |  | ||||||
|     let mut audio_streams: Vec<AudioStream> = Vec::new(); |  | ||||||
| 
 |  | ||||||
|     for f in formats { |  | ||||||
|         if f.format_type == player::FormatType::FormatStreamTypeOtf { |  | ||||||
|             continue; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         match (f.is_video(), f.is_audio()) { |  | ||||||
|             (true, true) => match map_video_stream(&f, deobf, &mut last_nsig) { |  | ||||||
|                 Some(stream) => video_streams.push(stream), |  | ||||||
|                 None => {} |  | ||||||
|             }, |  | ||||||
|             (true, false) => match map_video_stream(&f, deobf, &mut last_nsig) { |  | ||||||
|                 Some(stream) => video_only_streams.push(stream), |  | ||||||
|                 None => {} |  | ||||||
|             }, |  | ||||||
|             (false, true) => match map_audio_stream(&f, deobf, &mut last_nsig) { |  | ||||||
|                 Some(stream) => audio_streams.push(stream), |  | ||||||
|                 None => {} |  | ||||||
|             }, |  | ||||||
|             (false, false) => {} |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     video_streams.sort(); |  | ||||||
|     video_only_streams.sort(); |  | ||||||
|     audio_streams.sort(); |  | ||||||
| 
 |  | ||||||
|     let subtitles = response.captions.map_or(vec![], |captions| { |  | ||||||
|         captions |  | ||||||
|             .player_captions_tracklist_renderer |  | ||||||
|             .caption_tracks |  | ||||||
|             .iter() |  | ||||||
|             .map(|caption| { |  | ||||||
|                 let lang_auto = caption.name.strip_suffix(" (auto-generated)"); |  | ||||||
| 
 |  | ||||||
|                 Subtitle { |  | ||||||
|                     url: caption.base_url.to_owned(), |  | ||||||
|                     lang: caption.language_code.to_owned(), |  | ||||||
|                     lang_name: lang_auto.unwrap_or(&caption.name).to_owned(), |  | ||||||
|                     auto_generated: lang_auto.is_some(), |  | ||||||
|                 } |  | ||||||
|             }) |  | ||||||
|             .collect() |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     Ok(VideoPlayer { |  | ||||||
|         info: video_info, |  | ||||||
|         video_streams, |  | ||||||
|         video_only_streams, |  | ||||||
|         audio_streams, |  | ||||||
|         subtitles, |  | ||||||
|         expires_in_seconds: streaming_data.expires_in_seconds, |  | ||||||
|     }) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
|  | #[cfg(feature = "yaml")] | ||||||
| mod tests { | mod tests { | ||||||
|     use std::{fs::File, io::BufReader, path::Path}; |     use std::{fs::File, io::BufReader, path::Path}; | ||||||
| 
 | 
 | ||||||
|     use crate::{cache::DeobfData, client::CLIENT_TYPES}; |     use crate::{ | ||||||
|  |         client::{RustyPipe, CLIENT_TYPES}, | ||||||
|  |         deobfuscate::DeobfData, | ||||||
|  |         report::TestFileReporter, | ||||||
|  |     }; | ||||||
| 
 | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
|  | @ -465,8 +586,6 @@ mod tests { | ||||||
|         let tf_dir = Path::new("testfiles/player"); |         let tf_dir = Path::new("testfiles/player"); | ||||||
|         let video_id = "pPvd8UxmSbQ"; |         let video_id = "pPvd8UxmSbQ"; | ||||||
| 
 | 
 | ||||||
|         let rt = RustyTube::new(); |  | ||||||
| 
 |  | ||||||
|         for client_type in CLIENT_TYPES { |         for client_type in CLIENT_TYPES { | ||||||
|             let mut json_path = tf_dir.to_path_buf(); |             let mut json_path = tf_dir.to_path_buf(); | ||||||
|             json_path.push(format!("{:?}_video.json", client_type).to_lowercase()); |             json_path.push(format!("{:?}_video.json", client_type).to_lowercase()); | ||||||
|  | @ -474,31 +593,20 @@ mod tests { | ||||||
|                 continue; |                 continue; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             let client = rt.get_ytclient(client_type); |             let reporter = TestFileReporter::new(json_path); | ||||||
|             let context = client.get_context(false).await; |             let rp = RustyPipe::new(None, Some(Box::new(reporter)), None); | ||||||
| 
 |             rp.test_query() | ||||||
|             let request_body = build_request_body(client.clone(), &DEOBFUSCATOR, context, video_id); |                 .report(true) | ||||||
| 
 |                 .get_player(video_id, client_type) | ||||||
|             let resp = client |  | ||||||
|                 .request_builder(Method::POST, "player") |  | ||||||
|                 .await |                 .await | ||||||
|                 .json(&request_body) |  | ||||||
|                 .send() |  | ||||||
|                 .await |  | ||||||
|                 .unwrap() |  | ||||||
|                 .error_for_status() |  | ||||||
|                 .unwrap(); |                 .unwrap(); | ||||||
| 
 |  | ||||||
|             let mut file = File::create(json_path).unwrap(); |  | ||||||
|             let mut content = std::io::Cursor::new(resp.bytes().await.unwrap()); |  | ||||||
|             std::io::copy(&mut content, &mut file).unwrap(); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[test_log::test(tokio::test)] |     #[test_log::test(tokio::test)] | ||||||
|     async fn download_model_testfiles() { |     async fn download_model_testfiles() { | ||||||
|         let tf_dir = Path::new("testfiles/player_model"); |         let tf_dir = Path::new("testfiles/player_model"); | ||||||
|         let rt = RustyTube::new(); |         let rp = RustyPipe::new_test(); | ||||||
| 
 | 
 | ||||||
|         for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] { |         for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] { | ||||||
|             let mut json_path = tf_dir.to_path_buf(); |             let mut json_path = tf_dir.to_path_buf(); | ||||||
|  | @ -507,7 +615,11 @@ mod tests { | ||||||
|                 continue; |                 continue; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             let player_data = rt.get_player(id, ClientType::Desktop).await.unwrap(); |             let player_data = rp | ||||||
|  |                 .test_query() | ||||||
|  |                 .get_player(id, ClientType::Desktop) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|             let file = File::create(json_path).unwrap(); |             let file = File::create(json_path).unwrap(); | ||||||
|             serde_json::to_writer_pretty(file, &player_data).unwrap(); |             serde_json::to_writer_pretty(file, &player_data).unwrap(); | ||||||
|         } |         } | ||||||
|  | @ -525,10 +637,17 @@ mod tests { | ||||||
|         let json_file = File::open(json_path).unwrap(); |         let json_file = File::open(json_path).unwrap(); | ||||||
| 
 | 
 | ||||||
|         let resp: response::Player = serde_json::from_reader(BufReader::new(json_file)).unwrap(); |         let resp: response::Player = serde_json::from_reader(BufReader::new(json_file)).unwrap(); | ||||||
|         let player_data = map_player_data(resp, &DEOBFUSCATOR).unwrap(); |         let map_res = resp | ||||||
|  |             .map_response("pPvd8UxmSbQ", Language::En, Some(&DEOBFUSCATOR)) | ||||||
|  |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|  |         assert!( | ||||||
|  |             map_res.warnings.is_empty(), | ||||||
|  |             "deserialization/mapping warnings: {:?}", | ||||||
|  |             map_res.warnings | ||||||
|  |         ); | ||||||
|         let is_desktop = name == "desktop" || name == "desktopmusic"; |         let is_desktop = name == "desktop" || name == "desktopmusic"; | ||||||
|         insta::assert_yaml_snapshot!(format!("map_player_data_{}", name), player_data, { |         insta::assert_yaml_snapshot!(format!("map_player_data_{}", name), map_res.c, { | ||||||
|             ".info.publish_date" => insta::dynamic_redaction(move |value, _path| { |             ".info.publish_date" => insta::dynamic_redaction(move |value, _path| { | ||||||
|                 if is_desktop { |                 if is_desktop { | ||||||
|                     assert!(value.as_str().unwrap().starts_with("2019-05-30T00:00:00")); |                     assert!(value.as_str().unwrap().starts_with("2019-05-30T00:00:00")); | ||||||
|  | @ -561,8 +680,12 @@ mod tests { | ||||||
|     #[case::ios(ClientType::Ios)] |     #[case::ios(ClientType::Ios)] | ||||||
|     #[test_log::test(tokio::test)] |     #[test_log::test(tokio::test)] | ||||||
|     async fn t_get_player(#[case] client_type: ClientType) { |     async fn t_get_player(#[case] client_type: ClientType) { | ||||||
|         let rt = RustyTube::new(); |         let rp = RustyPipe::new_test(); | ||||||
|         let player_data = rt.get_player("n4tK7LYFxI0", client_type).await.unwrap(); |         let player_data = rp | ||||||
|  |             .test_query() | ||||||
|  |             .get_player("n4tK7LYFxI0", client_type) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|         // dbg!(&player_data);
 |         // dbg!(&player_data);
 | ||||||
| 
 | 
 | ||||||
|  | @ -584,7 +707,12 @@ mod tests { | ||||||
|         assert_eq!(player_data.info.is_live_content, false); |         assert_eq!(player_data.info.is_live_content, false); | ||||||
| 
 | 
 | ||||||
|         if client_type == ClientType::Desktop || client_type == ClientType::DesktopMusic { |         if client_type == ClientType::Desktop || client_type == ClientType::DesktopMusic { | ||||||
|             assert!(player_data.info.publish_date.unwrap().to_string().starts_with("2013-05-05 00:00:00")); |             assert!(player_data | ||||||
|  |                 .info | ||||||
|  |                 .publish_date | ||||||
|  |                 .unwrap() | ||||||
|  |                 .to_string() | ||||||
|  |                 .starts_with("2013-05-05 00:00:00")); | ||||||
|             assert_eq!(player_data.info.category.unwrap(), "Music"); |             assert_eq!(player_data.info.category.unwrap(), "Music"); | ||||||
|             assert_eq!(player_data.info.is_family_safe.unwrap(), true); |             assert_eq!(player_data.info.is_family_safe.unwrap(), true); | ||||||
|         } |         } | ||||||
|  | @ -604,7 +732,7 @@ mod tests { | ||||||
|             // Bitrates may change between requests
 |             // Bitrates may change between requests
 | ||||||
|             assert_approx(video.bitrate as f64, 1507068.0); |             assert_approx(video.bitrate as f64, 1507068.0); | ||||||
|             assert_eq!(video.average_bitrate, 1345149); |             assert_eq!(video.average_bitrate, 1345149); | ||||||
|             assert_eq!(video.size, 43553412); |             assert_eq!(video.size.unwrap(), 43553412); | ||||||
|             assert_eq!(video.width, 1280); |             assert_eq!(video.width, 1280); | ||||||
|             assert_eq!(video.height, 720); |             assert_eq!(video.height, 720); | ||||||
|             assert_eq!(video.fps, 30); |             assert_eq!(video.fps, 30); | ||||||
|  | @ -634,7 +762,7 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|             assert_approx(video.bitrate as f64, 1340829.0); |             assert_approx(video.bitrate as f64, 1340829.0); | ||||||
|             assert_approx(video.average_bitrate as f64, 1233444.0); |             assert_approx(video.average_bitrate as f64, 1233444.0); | ||||||
|             assert_approx(video.size as f64, 39936630.0); |             assert_approx(video.size.unwrap() as f64, 39936630.0); | ||||||
|             assert_eq!(video.width, 1280); |             assert_eq!(video.width, 1280); | ||||||
|             assert_eq!(video.height, 720); |             assert_eq!(video.height, 720); | ||||||
|             assert_eq!(video.fps, 30); |             assert_eq!(video.fps, 30); | ||||||
|  | @ -661,15 +789,20 @@ mod tests { | ||||||
|     fn t_cipher_to_url() { |     fn t_cipher_to_url() { | ||||||
|         let signature_cipher = "s=w%3DAe%3DA6aDNQLkViKS7LOm9QtxZJHKwb53riq9qEFw-ecBWJCAiA%3DcEg0tn3dty9jEHszfzh4Ud__bg9CEHVx4ix-7dKsIPAhIQRw8JQ0qOA&sp=sig&url=https://rr5---sn-h0jelnez.googlevideo.com/videoplayback%3Fexpire%3D1659376413%26ei%3Dvb7nYvH5BMK8gAfBj7ToBQ%26ip%3D2003%253Ade%253Aaf06%253A6300%253Ac750%253A1b77%253Ac74a%253A80e3%26id%3Do-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2%26itag%3D251%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DhH%26mm%3D31%252C26%26mn%3Dsn-h0jelnez%252Csn-4g5ednsl%26ms%3Dau%252Conr%26mv%3Dm%26mvi%3D5%26pl%3D37%26initcwndbps%3D1588750%26spc%3DlT-Khi831z8dTejFIRCvCEwx_6romtM%26vprv%3D1%26mime%3Daudio%252Fwebm%26ns%3Db_Mq_qlTFcSGlG9RpwpM9xQH%26gir%3Dyes%26clen%3D3781277%26dur%3D229.301%26lmt%3D1655510291473933%26mt%3D1659354538%26fvip%3D5%26keepalive%3Dyes%26fexp%3D24001373%252C24007246%26c%3DWEB%26rbqsm%3Dfr%26txp%3D4532434%26n%3Dd2g6G2hVqWIXxedQ%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cspc%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%253D%253D"; |         let signature_cipher = "s=w%3DAe%3DA6aDNQLkViKS7LOm9QtxZJHKwb53riq9qEFw-ecBWJCAiA%3DcEg0tn3dty9jEHszfzh4Ud__bg9CEHVx4ix-7dKsIPAhIQRw8JQ0qOA&sp=sig&url=https://rr5---sn-h0jelnez.googlevideo.com/videoplayback%3Fexpire%3D1659376413%26ei%3Dvb7nYvH5BMK8gAfBj7ToBQ%26ip%3D2003%253Ade%253Aaf06%253A6300%253Ac750%253A1b77%253Ac74a%253A80e3%26id%3Do-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2%26itag%3D251%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DhH%26mm%3D31%252C26%26mn%3Dsn-h0jelnez%252Csn-4g5ednsl%26ms%3Dau%252Conr%26mv%3Dm%26mvi%3D5%26pl%3D37%26initcwndbps%3D1588750%26spc%3DlT-Khi831z8dTejFIRCvCEwx_6romtM%26vprv%3D1%26mime%3Daudio%252Fwebm%26ns%3Db_Mq_qlTFcSGlG9RpwpM9xQH%26gir%3Dyes%26clen%3D3781277%26dur%3D229.301%26lmt%3D1655510291473933%26mt%3D1659354538%26fvip%3D5%26keepalive%3Dyes%26fexp%3D24001373%252C24007246%26c%3DWEB%26rbqsm%3Dfr%26txp%3D4532434%26n%3Dd2g6G2hVqWIXxedQ%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cspc%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%253D%253D"; | ||||||
|         let mut last_nsig: [String; 2] = ["".to_owned(), "".to_owned()]; |         let mut last_nsig: [String; 2] = ["".to_owned(), "".to_owned()]; | ||||||
|         let (url, throttled) = map_url( |         let map_res = map_url( | ||||||
|             &None, |             &None, | ||||||
|             &Some(signature_cipher.to_owned()), |             &Some(signature_cipher.to_owned()), | ||||||
|             &DEOBFUSCATOR, |             &DEOBFUSCATOR, | ||||||
|             &mut last_nsig, |             &mut last_nsig, | ||||||
|         ) |         ); | ||||||
|         .unwrap(); |         let (url, throttled) = map_res.c.unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert_eq!(url, "https://rr5---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=3781277&dur=229.301&ei=vb7nYvH5BMK8gAfBj7ToBQ&expire=1659376413&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2&initcwndbps=1588750&ip=2003%3Ade%3Aaf06%3A6300%3Ac750%3A1b77%3Ac74a%3A80e3&itag=251&keepalive=yes&lmt=1655510291473933&lsig=AG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=hH&mime=audio%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5ednsl&ms=au%2Conr&mt=1659354538&mv=m&mvi=5&n=XzXGSfGusw6OCQ&ns=b_Mq_qlTFcSGlG9RpwpM9xQH&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAPIsKd7-xi4xVHEC9gb__dU4hzfzsHEj9ytd3nt0gEceAiACJWBcw-wFEq9qir35bwKHJZxtQ9mOL7SKiVkLQNDa6A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&spc=lT-Khi831z8dTejFIRCvCEwx_6romtM&txp=4532434&vprv=1"); |         assert_eq!(url, "https://rr5---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=3781277&dur=229.301&ei=vb7nYvH5BMK8gAfBj7ToBQ&expire=1659376413&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2&initcwndbps=1588750&ip=2003%3Ade%3Aaf06%3A6300%3Ac750%3A1b77%3Ac74a%3A80e3&itag=251&keepalive=yes&lmt=1655510291473933&lsig=AG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=hH&mime=audio%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5ednsl&ms=au%2Conr&mt=1659354538&mv=m&mvi=5&n=XzXGSfGusw6OCQ&ns=b_Mq_qlTFcSGlG9RpwpM9xQH&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAPIsKd7-xi4xVHEC9gb__dU4hzfzsHEj9ytd3nt0gEceAiACJWBcw-wFEq9qir35bwKHJZxtQ9mOL7SKiVkLQNDa6A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&spc=lT-Khi831z8dTejFIRCvCEwx_6romtM&txp=4532434&vprv=1"); | ||||||
|         assert_eq!(throttled, false); |         assert_eq!(throttled, false); | ||||||
|  |         assert!( | ||||||
|  |             map_res.warnings.is_empty(), | ||||||
|  |             "deserialization/mapping warnings: {:?}", | ||||||
|  |             map_res.warnings | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,14 +1,15 @@ | ||||||
| use anyhow::{anyhow, Context, Result}; | use anyhow::{anyhow, bail, Result}; | ||||||
| use reqwest::Method; | use reqwest::Method; | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|  |     deobfuscate::Deobfuscator, | ||||||
|     model::{Channel, Language, Playlist, Thumbnail, Video}, |     model::{Channel, Language, Playlist, Thumbnail, Video}, | ||||||
|     serializer::text::{PageType, TextLink}, |     serializer::text::{PageType, TextLink}, | ||||||
|     timeago, util, |     timeago, util, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::{response, ClientType, ContextYT, RustyTube}; | use super::{response, ClientType, ContextYT, MapResponse, MapResult, RustyPipeQuery}; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Debug, Serialize)] | #[derive(Clone, Debug, Serialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -24,62 +25,44 @@ struct QPlaylistCont { | ||||||
|     continuation: String, |     continuation: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl RustyTube { | impl RustyPipeQuery { | ||||||
|     pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> { |     pub async fn get_playlist(self, playlist_id: &str) -> Result<Playlist> { | ||||||
|         let client = self.get_ytclient(ClientType::Desktop); |         let context = self.get_context(ClientType::Desktop, true).await; | ||||||
|         let context = client.get_context(true).await; |  | ||||||
| 
 |  | ||||||
|         let request_body = QPlaylist { |         let request_body = QPlaylist { | ||||||
|             context, |             context, | ||||||
|             browse_id: "VL".to_owned() + playlist_id, |             browse_id: "VL".to_owned() + playlist_id, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let resp = client |         self.execute_request::<response::Playlist, _, _>( | ||||||
|             .request_builder(Method::POST, "browse") |             ClientType::Desktop, | ||||||
|  |             "get_playlist", | ||||||
|  |             Method::POST, | ||||||
|  |             "browse", | ||||||
|  |             playlist_id, | ||||||
|  |             &request_body, | ||||||
|  |         ) | ||||||
|         .await |         .await | ||||||
|             .json(&request_body) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status()?; |  | ||||||
| 
 |  | ||||||
|         let resp_body = resp.text().await?; |  | ||||||
|         let playlist_response = |  | ||||||
|             serde_json::from_str::<response::Playlist>(&resp_body).context(resp_body)?; |  | ||||||
| 
 |  | ||||||
|         map_playlist(&playlist_response, self.localization.language) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub async fn get_playlist_cont(&self, playlist: &mut Playlist) -> Result<()> { |     pub async fn get_playlist_cont(self, playlist: &mut Playlist) -> Result<()> { | ||||||
|         match &playlist.ctoken { |         match &playlist.ctoken { | ||||||
|             Some(ctoken) => { |             Some(ctoken) => { | ||||||
|                 let client = self.get_ytclient(ClientType::Desktop); |                 let context = self.get_context(ClientType::Desktop, true).await; | ||||||
|                 let context = client.get_context(true).await; |  | ||||||
| 
 |  | ||||||
|                 let request_body = QPlaylistCont { |                 let request_body = QPlaylistCont { | ||||||
|                     context, |                     context, | ||||||
|                     continuation: ctoken.to_owned(), |                     continuation: ctoken.to_owned(), | ||||||
|                 }; |                 }; | ||||||
| 
 | 
 | ||||||
|                 let resp = client |                 let (mut videos, ctoken) = self | ||||||
|                     .request_builder(Method::POST, "browse") |                     .execute_request::<response::PlaylistCont, _, _>( | ||||||
|                     .await |                         ClientType::Desktop, | ||||||
|                     .json(&request_body) |                         "get_playlist_cont", | ||||||
|                     .send() |                         Method::POST, | ||||||
|                     .await? |                         "browse", | ||||||
|                     .error_for_status()?; |                         &playlist.id, | ||||||
| 
 |                         &request_body, | ||||||
|                 let cont_response = resp.json::<response::playlist::PlaylistCont>().await?; |                     ) | ||||||
| 
 |                     .await?; | ||||||
|                 let action = some_or_bail!( |  | ||||||
|                     cont_response |  | ||||||
|                         .on_response_received_actions |  | ||||||
|                         .iter() |  | ||||||
|                         .find(|a| a.append_continuation_items_action.target_id == playlist.id), |  | ||||||
|                     Err(anyhow!("no continuation action")) |  | ||||||
|                 ); |  | ||||||
| 
 |  | ||||||
|                 let (mut videos, ctoken) = |  | ||||||
|                     map_playlist_items(&action.append_continuation_items_action.continuation_items); |  | ||||||
| 
 | 
 | ||||||
|                 playlist.videos.append(&mut videos); |                 playlist.videos.append(&mut videos); | ||||||
|                 playlist.ctoken = ctoken; |                 playlist.ctoken = ctoken; | ||||||
|  | @ -95,12 +78,17 @@ impl RustyTube { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_playlist(response: &response::Playlist, lang: Language) -> Result<Playlist> { | impl MapResponse<Playlist> for response::Playlist { | ||||||
|  |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         id: &str, | ||||||
|  |         lang: Language, | ||||||
|  |         _deobf: Option<&Deobfuscator>, | ||||||
|  |     ) -> Result<MapResult<Playlist>> { | ||||||
|         let video_items = &some_or_bail!( |         let video_items = &some_or_bail!( | ||||||
|             some_or_bail!( |             some_or_bail!( | ||||||
|                 some_or_bail!( |                 some_or_bail!( | ||||||
|                 response |                     self.contents | ||||||
|                     .contents |  | ||||||
|                         .two_column_browse_results_renderer |                         .two_column_browse_results_renderer | ||||||
|                         .contents |                         .contents | ||||||
|                         .get(0), |                         .get(0), | ||||||
|  | @ -121,9 +109,9 @@ fn map_playlist(response: &response::Playlist, lang: Language) -> Result<Playlis | ||||||
|         .playlist_video_list_renderer |         .playlist_video_list_renderer | ||||||
|         .contents; |         .contents; | ||||||
| 
 | 
 | ||||||
|     let (videos, ctoken) = map_playlist_items(video_items); |         let (videos, ctoken) = map_playlist_items(&video_items.c); | ||||||
| 
 | 
 | ||||||
|     let (thumbnails, last_update_txt) = match &response.sidebar { |         let (thumbnails, last_update_txt) = match &self.sidebar { | ||||||
|             Some(sidebar) => { |             Some(sidebar) => { | ||||||
|                 let primary = some_or_bail!( |                 let primary = some_or_bail!( | ||||||
|                     sidebar.playlist_sidebar_renderer.items.get(0), |                     sidebar.playlist_sidebar_renderer.items.get(0), | ||||||
|  | @ -146,14 +134,11 @@ fn map_playlist(response: &response::Playlist, lang: Language) -> Result<Playlis | ||||||
|             } |             } | ||||||
|             None => { |             None => { | ||||||
|                 let header_banner = some_or_bail!( |                 let header_banner = some_or_bail!( | ||||||
|                 &response |                     &self.header.playlist_header_renderer.playlist_header_banner, | ||||||
|                     .header |  | ||||||
|                     .playlist_header_renderer |  | ||||||
|                     .playlist_header_banner, |  | ||||||
|                     Err(anyhow!("no thumbnail found")) |                     Err(anyhow!("no thumbnail found")) | ||||||
|                 ); |                 ); | ||||||
| 
 | 
 | ||||||
|             let last_update_txt = response |                 let last_update_txt = self | ||||||
|                     .header |                     .header | ||||||
|                     .playlist_header_renderer |                     .playlist_header_renderer | ||||||
|                     .byline |                     .byline | ||||||
|  | @ -182,45 +167,48 @@ fn map_playlist(response: &response::Playlist, lang: Language) -> Result<Playlis | ||||||
|         let n_videos = match ctoken { |         let n_videos = match ctoken { | ||||||
|             Some(_) => { |             Some(_) => { | ||||||
|                 ok_or_bail!( |                 ok_or_bail!( | ||||||
|                 util::parse_numeric(&response.header.playlist_header_renderer.num_videos_text), |                     util::parse_numeric(&self.header.playlist_header_renderer.num_videos_text), | ||||||
|                     Err(anyhow!("no video count")) |                     Err(anyhow!("no video count")) | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|             None => videos.len() as u32, |             None => videos.len() as u32, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|     let id = response |         let playlist_id = self.header.playlist_header_renderer.playlist_id; | ||||||
|         .header |         if playlist_id != id { | ||||||
|         .playlist_header_renderer |             bail!("got wrong playlist id {}, expected {}", playlist_id, id); | ||||||
|         .playlist_id |         } | ||||||
|         .to_owned(); |  | ||||||
|     let name = response.header.playlist_header_renderer.title.to_owned(); |  | ||||||
|     let description = response |  | ||||||
|         .header |  | ||||||
|         .playlist_header_renderer |  | ||||||
|         .description_text |  | ||||||
|         .to_owned(); |  | ||||||
| 
 | 
 | ||||||
|     let channel = match &response.header.playlist_header_renderer.owner_text { |         let name = self.header.playlist_header_renderer.title; | ||||||
|         Some(owner_text) => match owner_text { |         let description = self.header.playlist_header_renderer.description_text; | ||||||
|             TextLink::Browse { | 
 | ||||||
|  |         let channel = match self.header.playlist_header_renderer.owner_text { | ||||||
|  |             Some(TextLink::Browse { | ||||||
|                 text, |                 text, | ||||||
|                 page_type, |                 page_type: PageType::Channel, | ||||||
|                 browse_id, |                 browse_id, | ||||||
|             } => match page_type { |             }) => Some(Channel { | ||||||
|                 PageType::Channel => Some(Channel { |                 id: browse_id, | ||||||
|                     id: browse_id.to_owned(), |                 name: text, | ||||||
|                     name: text.to_owned(), |  | ||||||
|             }), |             }), | ||||||
|             _ => None, |             _ => None, | ||||||
|             }, |         }; | ||||||
|             _ => None, | 
 | ||||||
|         }, |         let mut warnings = video_items.warnings.to_owned(); | ||||||
|  |         let last_update = match &last_update_txt { | ||||||
|  |             Some(textual_date) => { | ||||||
|  |                 let parsed = timeago::parse_textual_date_to_dt(lang, textual_date); | ||||||
|  |                 if parsed.is_none() { | ||||||
|  |                     warnings.push(format!("could not parse textual date `{}`", textual_date)); | ||||||
|  |                 } | ||||||
|  |                 parsed | ||||||
|  |             } | ||||||
|             None => None, |             None => None, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|     Ok(Playlist { |         Ok(MapResult { | ||||||
|         id, |             c: Playlist { | ||||||
|  |                 id: playlist_id, | ||||||
|                 name, |                 name, | ||||||
|                 videos, |                 videos, | ||||||
|                 n_videos, |                 n_videos, | ||||||
|  | @ -228,16 +216,41 @@ fn map_playlist(response: &response::Playlist, lang: Language) -> Result<Playlis | ||||||
|                 thumbnails, |                 thumbnails, | ||||||
|                 description, |                 description, | ||||||
|                 channel, |                 channel, | ||||||
|         last_update: match &last_update_txt { |                 last_update, | ||||||
|             Some(textual_date) => timeago::parse_textual_date_to_dt(lang, textual_date), |  | ||||||
|             None => None, |  | ||||||
|         }, |  | ||||||
|                 last_update_txt, |                 last_update_txt, | ||||||
|  |             }, | ||||||
|  |             warnings, | ||||||
|         }) |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl MapResponse<(Vec<Video>, Option<String>)> for response::PlaylistCont { | ||||||
|  |     fn map_response( | ||||||
|  |         self, | ||||||
|  |         id: &str, | ||||||
|  |         _lang: Language, | ||||||
|  |         _deobf: Option<&Deobfuscator>, | ||||||
|  |     ) -> Result<MapResult<(Vec<Video>, Option<String>)>> { | ||||||
|  |         let action = some_or_bail!( | ||||||
|  |             self.on_response_received_actions | ||||||
|  |                 .iter() | ||||||
|  |                 .find(|a| a.append_continuation_items_action.target_id == id), | ||||||
|  |             Err(anyhow!("no continuation action")) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         Ok(MapResult { | ||||||
|  |             c: map_playlist_items(&action.append_continuation_items_action.continuation_items.c), | ||||||
|  |             warnings: action | ||||||
|  |                 .append_continuation_items_action | ||||||
|  |                 .continuation_items | ||||||
|  |                 .warnings | ||||||
|  |                 .to_owned(), | ||||||
|  |         }) | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn map_playlist_items( | fn map_playlist_items( | ||||||
|     items: &Vec<response::VideoListItem<response::playlist::PlaylistVideo>>, |     items: &[response::VideoListItem<response::playlist::PlaylistVideo>], | ||||||
| ) -> (Vec<Video>, Option<String>) { | ) -> (Vec<Video>, Option<String>) { | ||||||
|     let mut ctoken: Option<String> = None; |     let mut ctoken: Option<String> = None; | ||||||
|     let videos = items |     let videos = items | ||||||
|  | @ -246,10 +259,9 @@ fn map_playlist_items( | ||||||
|             response::VideoListItem::GridVideoRenderer { video } => match &video.channel { |             response::VideoListItem::GridVideoRenderer { video } => match &video.channel { | ||||||
|                 TextLink::Browse { |                 TextLink::Browse { | ||||||
|                     text, |                     text, | ||||||
|                     page_type, |                     page_type: PageType::Channel, | ||||||
|                     browse_id, |                     browse_id, | ||||||
|                 } => match page_type { |                 } => Some(Video { | ||||||
|                     PageType::Channel => Some(Video { |  | ||||||
|                     id: video.video_id.to_owned(), |                     id: video.video_id.to_owned(), | ||||||
|                     title: video.title.to_owned(), |                     title: video.title.to_owned(), | ||||||
|                     length: video.length_seconds, |                     length: video.length_seconds, | ||||||
|  | @ -270,8 +282,6 @@ fn map_playlist_items( | ||||||
|                 }), |                 }), | ||||||
|                 _ => None, |                 _ => None, | ||||||
|             }, |             }, | ||||||
|                 _ => None, |  | ||||||
|             }, |  | ||||||
|             response::VideoListItem::ContinuationItemRenderer { |             response::VideoListItem::ContinuationItemRenderer { | ||||||
|                 continuation_endpoint, |                 continuation_endpoint, | ||||||
|             } => { |             } => { | ||||||
|  | @ -289,49 +299,10 @@ mod tests { | ||||||
| 
 | 
 | ||||||
|     use rstest::rstest; |     use rstest::rstest; | ||||||
| 
 | 
 | ||||||
|  |     use crate::{client::RustyPipe, report::TestFileReporter}; | ||||||
|  | 
 | ||||||
|     use super::*; |     use super::*; | ||||||
| 
 | 
 | ||||||
|     #[test_log::test(tokio::test)] |  | ||||||
|     async fn download_testfiles() { |  | ||||||
|         let tf_dir = Path::new("testfiles/playlist"); |  | ||||||
| 
 |  | ||||||
|         let rt = RustyTube::new(); |  | ||||||
| 
 |  | ||||||
|         for (name, id) in [ |  | ||||||
|             ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), |  | ||||||
|             ("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"), |  | ||||||
|             ("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"), |  | ||||||
|         ] { |  | ||||||
|             let mut json_path = tf_dir.to_path_buf(); |  | ||||||
|             json_path.push(format!("playlist_{}.json", name)); |  | ||||||
|             if json_path.exists() { |  | ||||||
|                 continue; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             let client = rt.get_ytclient(ClientType::Desktop); |  | ||||||
|             let context = client.get_context(false).await; |  | ||||||
| 
 |  | ||||||
|             let request_body = QPlaylist { |  | ||||||
|                 context, |  | ||||||
|                 browse_id: "VL".to_owned() + id, |  | ||||||
|             }; |  | ||||||
| 
 |  | ||||||
|             let resp = client |  | ||||||
|                 .request_builder(Method::POST, "browse") |  | ||||||
|                 .await |  | ||||||
|                 .json(&request_body) |  | ||||||
|                 .send() |  | ||||||
|                 .await |  | ||||||
|                 .unwrap() |  | ||||||
|                 .error_for_status() |  | ||||||
|                 .unwrap(); |  | ||||||
| 
 |  | ||||||
|             let mut file = std::fs::File::create(json_path).unwrap(); |  | ||||||
|             let mut content = std::io::Cursor::new(resp.bytes().await.unwrap()); |  | ||||||
|             std::io::copy(&mut content, &mut file).unwrap(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::long(
 |     #[case::long(
 | ||||||
|         "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ", |         "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ", | ||||||
|  | @ -368,8 +339,8 @@ mod tests { | ||||||
|         #[case] description: Option<String>, |         #[case] description: Option<String>, | ||||||
|         #[case] channel: Option<Channel>, |         #[case] channel: Option<Channel>, | ||||||
|     ) { |     ) { | ||||||
|         let rt = RustyTube::new(); |         let rp = RustyPipe::new_test(); | ||||||
|         let playlist = rt.get_playlist(id).await.unwrap(); |         let playlist = rp.test_query().get_playlist(id).await.unwrap(); | ||||||
| 
 | 
 | ||||||
|         assert_eq!(playlist.id, id); |         assert_eq!(playlist.id, id); | ||||||
|         assert_eq!(playlist.name, name); |         assert_eq!(playlist.name, name); | ||||||
|  | @ -378,37 +349,70 @@ mod tests { | ||||||
|         assert!(playlist.n_videos > 10); |         assert!(playlist.n_videos > 10); | ||||||
|         assert_eq!(playlist.n_videos > 100, is_long); |         assert_eq!(playlist.n_videos > 100, is_long); | ||||||
|         assert_eq!(playlist.description, description); |         assert_eq!(playlist.description, description); | ||||||
|  |         if channel.is_some() { | ||||||
|             assert_eq!(playlist.channel, channel); |             assert_eq!(playlist.channel, channel); | ||||||
|  |         } | ||||||
|         assert!(!playlist.thumbnails.is_empty()); |         assert!(!playlist.thumbnails.is_empty()); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     #[test_log::test(tokio::test)] | ||||||
|  |     async fn download_testfiles() { | ||||||
|  |         let tf_dir = Path::new("testfiles/playlist"); | ||||||
|  | 
 | ||||||
|  |         for (name, id) in [ | ||||||
|  |             ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), | ||||||
|  |             ("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"), | ||||||
|  |             ("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"), | ||||||
|  |         ] { | ||||||
|  |             let mut json_path = tf_dir.to_path_buf(); | ||||||
|  |             json_path.push(format!("playlist_{}.json", name)); | ||||||
|  |             if json_path.exists() { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             let reporter = TestFileReporter::new(json_path); | ||||||
|  |             let rp = RustyPipe::new(None, Some(Box::new(reporter)), None); | ||||||
|  |             rp.test_query().report(true).get_playlist(id).await.unwrap(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     #[rstest] |     #[rstest] | ||||||
|     #[case::long("long")] |     #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] | ||||||
|     #[case::short("short")] |     #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] | ||||||
|     #[case::nomusic("nomusic")] |     #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] | ||||||
|     fn t_map_playlist_data(#[case] name: &str) { |     fn t_map_playlist_data(#[case] name: &str, #[case] id: &str) { | ||||||
|         let filename = format!("testfiles/playlist/playlist_{}.json", name); |         let filename = format!("testfiles/playlist/playlist_{}.json", name); | ||||||
|         let json_path = Path::new(&filename); |         let json_path = Path::new(&filename); | ||||||
|         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 playlist_data = map_playlist(&playlist, Language::En).unwrap(); |         let map_res = playlist.map_response(id, Language::En, None).unwrap(); | ||||||
|         insta::assert_yaml_snapshot!(format!("map_playlist_data_{}", name), playlist_data, { | 
 | ||||||
|  |         assert!( | ||||||
|  |             map_res.warnings.is_empty(), | ||||||
|  |             "deserialization/mapping warnings: {:?}", | ||||||
|  |             map_res.warnings | ||||||
|  |         ); | ||||||
|  |         insta::assert_yaml_snapshot!(format!("map_playlist_data_{}", name), map_res.c, { | ||||||
|             ".last_update" => "[date]" |             ".last_update" => "[date]" | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[test_log::test(tokio::test)] |     #[test_log::test(tokio::test)] | ||||||
|     async fn t_playlist_cont() { |     async fn t_playlist_cont() { | ||||||
|         let rt = RustyTube::new(); |         let rp = RustyPipe::new_test(); | ||||||
|         let mut playlist = rt |         let mut playlist = rp | ||||||
|  |             .test_query() | ||||||
|             .get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi") |             .get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi") | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|         while playlist.ctoken.is_some() { |         while playlist.ctoken.is_some() { | ||||||
|             rt.get_playlist_cont(&mut playlist).await.unwrap(); |             rp.test_query() | ||||||
|  |                 .get_playlist_cont(&mut playlist) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         assert!(playlist.videos.len() > 100); |         assert!(playlist.videos.len() > 100); | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ use serde_with::VecSkipError; | ||||||
| 
 | 
 | ||||||
| use super::TimeOverlay; | use super::TimeOverlay; | ||||||
| use super::{ContentRenderer, ContentsRenderer, Thumbnails, VideoListItem}; | use super::{ContentRenderer, ContentsRenderer, Thumbnails, VideoListItem}; | ||||||
|  | use crate::serializer::text::Text; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -63,11 +64,11 @@ pub struct GridRenderer { | ||||||
| pub struct ChannelVideo { | pub struct ChannelVideo { | ||||||
|     pub video_id: String, |     pub video_id: String, | ||||||
|     pub thumbnail: Thumbnails, |     pub thumbnail: Thumbnails, | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub title: String, |     pub title: String, | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |     #[serde_as(as = "Option<Text>")] | ||||||
|     pub published_time_text: Option<String>, |     pub published_time_text: Option<String>, | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub view_count_text: String, |     pub view_count_text: String, | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|     pub thumbnail_overlays: Vec<TimeOverlay>, |     pub thumbnail_overlays: Vec<TimeOverlay>, | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ pub mod video; | ||||||
| pub use channel::Channel; | pub use channel::Channel; | ||||||
| pub use player::Player; | pub use player::Player; | ||||||
| pub use playlist::Playlist; | pub use playlist::Playlist; | ||||||
|  | pub use playlist::PlaylistCont; | ||||||
| pub use playlist_music::PlaylistMusic; | pub use playlist_music::PlaylistMusic; | ||||||
| pub use video::Video; | pub use video::Video; | ||||||
| pub use video::VideoComments; | pub use video::VideoComments; | ||||||
|  | @ -15,7 +16,7 @@ pub use video::VideoRecommendations; | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::{serde_as, DefaultOnError, VecSkipError}; | use serde_with::{serde_as, DefaultOnError, VecSkipError}; | ||||||
| 
 | 
 | ||||||
| use crate::serializer::text::TextLink; | use crate::serializer::text::{Text, TextLink, TextLinks}; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -93,10 +94,10 @@ pub struct VideoOwner { | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub struct VideoOwnerRenderer { | pub struct VideoOwnerRenderer { | ||||||
|     #[serde_as(as = "crate::serializer::text::TextLink")] |     #[serde_as(as = "TextLink")] | ||||||
|     pub title: TextLink, |     pub title: TextLink, | ||||||
|     pub thumbnail: Thumbnails, |     pub thumbnail: Thumbnails, | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |     #[serde_as(as = "Option<Text>")] | ||||||
|     pub subscriber_count_text: Option<String>, |     pub subscriber_count_text: Option<String>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecSkipError<_>")] | ||||||
|  | @ -132,7 +133,7 @@ pub struct TimeOverlay { | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub struct TimeOverlayRenderer { | pub struct TimeOverlayRenderer { | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub text: String, |     pub text: String, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |     #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|  | @ -198,7 +199,7 @@ pub struct MusicColumn { | ||||||
| #[serde_as] | #[serde_as] | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| pub struct MusicColumnRenderer { | pub struct MusicColumnRenderer { | ||||||
|     #[serde_as(as = "crate::serializer::text::TextLinks")] |     #[serde_as(as = "TextLinks")] | ||||||
|     pub text: Vec<TextLink>, |     pub text: Vec<TextLink>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -213,3 +214,23 @@ pub struct MusicContinuation { | ||||||
| pub struct MusicContinuationData { | pub struct MusicContinuationData { | ||||||
|     pub continuation: String, |     pub continuation: String, | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | impl From<Thumbnail> for crate::model::Thumbnail { | ||||||
|  |     fn from(tn: Thumbnail) -> Self { | ||||||
|  |         crate::model::Thumbnail { | ||||||
|  |             url: tn.url, | ||||||
|  |             width: tn.width, | ||||||
|  |             height: tn.height, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl From<Thumbnails> for Vec<crate::model::Thumbnail> { | ||||||
|  |     fn from(ts: Thumbnails) -> Self { | ||||||
|  |         let mut thumbnails = vec![]; | ||||||
|  |         for t in ts.thumbnails { | ||||||
|  |             thumbnails.push(t.into()); | ||||||
|  |         } | ||||||
|  |         thumbnails | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -3,9 +3,10 @@ use std::ops::Range; | ||||||
| use chrono::NaiveDate; | use chrono::NaiveDate; | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::serde_as; | use serde_with::serde_as; | ||||||
| use serde_with::{json::JsonString, DefaultOnError, VecSkipError}; | use serde_with::{json::JsonString, DefaultOnError}; | ||||||
| 
 | 
 | ||||||
| use super::Thumbnails; | use super::Thumbnails; | ||||||
|  | use crate::serializer::{text::Text, MapResult, VecLogError}; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
|  | @ -45,11 +46,11 @@ pub struct StreamingData { | ||||||
|     #[serde_as(as = "JsonString")] |     #[serde_as(as = "JsonString")] | ||||||
|     pub expires_in_seconds: u32, |     pub expires_in_seconds: u32, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub formats: Vec<Format>, |     pub formats: MapResult<Vec<Format>>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub adaptive_formats: 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
 | ||||||
|  | @ -73,20 +74,20 @@ pub struct Format { | ||||||
|     pub width: Option<u32>, |     pub width: Option<u32>, | ||||||
|     pub height: Option<u32>, |     pub height: Option<u32>, | ||||||
| 
 | 
 | ||||||
|     #[serde_as(as = "Option<crate::serializer::range::Range>")] |     #[serde_as(as = "Option<crate::serializer::Range>")] | ||||||
|     pub index_range: Option<Range<u32>>, |     pub index_range: Option<Range<u32>>, | ||||||
|     #[serde_as(as = "Option<crate::serializer::range::Range>")] |     #[serde_as(as = "Option<crate::serializer::Range>")] | ||||||
|     pub init_range: Option<Range<u32>>, |     pub init_range: Option<Range<u32>>, | ||||||
| 
 | 
 | ||||||
|     #[serde_as(as = "JsonString")] |     #[serde_as(as = "Option<JsonString>")] | ||||||
|     pub content_length: u64, |     pub content_length: Option<u64>, | ||||||
| 
 | 
 | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |     #[serde_as(deserialize_as = "DefaultOnError")] | ||||||
|     pub quality: Option<Quality>, |     pub quality: Option<Quality>, | ||||||
|     pub fps: Option<u8>, |     pub fps: Option<u8>, | ||||||
|     pub quality_label: Option<String>, |     pub quality_label: Option<String>, | ||||||
|     pub average_bitrate: u32, |     pub average_bitrate: Option<u32>, | ||||||
|     pub color_info: Option<ColorInfo>, |     pub color_info: Option<ColorInfo>, | ||||||
| 
 | 
 | ||||||
|     // Audio only
 |     // Audio only
 | ||||||
|  | @ -104,7 +105,9 @@ pub struct Format { | ||||||
| 
 | 
 | ||||||
| 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 { | ||||||
|  | @ -188,7 +191,7 @@ pub struct PlayerCaptionsTracklistRenderer { | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub struct CaptionTrack { | pub struct CaptionTrack { | ||||||
|     pub base_url: String, |     pub base_url: String, | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub name: String, |     pub name: String, | ||||||
|     pub language_code: String, |     pub language_code: String, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,7 +2,8 @@ use serde::Deserialize; | ||||||
| use serde_with::serde_as; | use serde_with::serde_as; | ||||||
| use serde_with::{json::JsonString, DefaultOnError, VecSkipError}; | use serde_with::{json::JsonString, DefaultOnError, VecSkipError}; | ||||||
| 
 | 
 | ||||||
| use crate::serializer::text::TextLink; | use crate::serializer::text::{Text, TextLink}; | ||||||
|  | use crate::serializer::{MapResult, VecLogError}; | ||||||
| 
 | 
 | ||||||
| use super::{ContentRenderer, ContentsRenderer, Thumbnails, ThumbnailsWrap, VideoListItem}; | use super::{ContentRenderer, ContentsRenderer, Thumbnails, ThumbnailsWrap, VideoListItem}; | ||||||
| 
 | 
 | ||||||
|  | @ -56,8 +57,8 @@ pub struct PlaylistVideoListRenderer { | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub struct PlaylistVideoList { | pub struct PlaylistVideoList { | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub contents: Vec<VideoListItem<PlaylistVideo>>, |     pub contents: MapResult<Vec<VideoListItem<PlaylistVideo>>>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[serde_as] | #[serde_as] | ||||||
|  | @ -66,10 +67,10 @@ pub struct PlaylistVideoList { | ||||||
| pub struct PlaylistVideo { | pub struct PlaylistVideo { | ||||||
|     pub video_id: String, |     pub video_id: String, | ||||||
|     pub thumbnail: Thumbnails, |     pub thumbnail: Thumbnails, | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub title: String, |     pub title: String, | ||||||
|     #[serde(rename = "shortBylineText")] |     #[serde(rename = "shortBylineText")] | ||||||
|     #[serde_as(as = "crate::serializer::text::TextLink")] |     #[serde_as(as = "TextLink")] | ||||||
|     pub channel: TextLink, |     pub channel: TextLink, | ||||||
|     #[serde_as(as = "JsonString")] |     #[serde_as(as = "JsonString")] | ||||||
|     pub length_seconds: u32, |     pub length_seconds: u32, | ||||||
|  | @ -86,14 +87,14 @@ pub struct Header { | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub struct HeaderRenderer { | pub struct HeaderRenderer { | ||||||
|     pub playlist_id: String, |     pub playlist_id: String, | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub title: String, |     pub title: String, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde_as(as = "DefaultOnError<Option<crate::serializer::text::Text>>")] |     #[serde_as(as = "DefaultOnError<Option<Text>>")] | ||||||
|     pub description_text: Option<String>, |     pub description_text: Option<String>, | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub num_videos_text: String, |     pub num_videos_text: String, | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::TextLink>")] |     #[serde_as(as = "Option<TextLink>")] | ||||||
|     pub owner_text: Option<TextLink>, |     pub owner_text: Option<TextLink>, | ||||||
| 
 | 
 | ||||||
|     // Alternative layout
 |     // Alternative layout
 | ||||||
|  | @ -118,7 +119,7 @@ pub struct Byline { | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub struct BylineRenderer { | pub struct BylineRenderer { | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |     #[serde_as(as = "Text")] | ||||||
|     pub text: String, |     pub text: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -150,7 +151,7 @@ pub struct SidebarPrimaryInfoRenderer { | ||||||
|     // - `"495", " videos"`
 |     // - `"495", " videos"`
 | ||||||
|     // - `"3,310,996 views"`
 |     // - `"3,310,996 views"`
 | ||||||
|     // - `"Last updated on ", "Aug 7, 2022"`
 |     // - `"Last updated on ", "Aug 7, 2022"`
 | ||||||
|     #[serde_as(as = "Vec<crate::serializer::text::Text>")] |     #[serde_as(as = "Vec<Text>")] | ||||||
|     pub stats: Vec<String>, |     pub stats: Vec<String>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -172,7 +173,7 @@ pub struct OnResponseReceivedAction { | ||||||
| #[derive(Clone, Debug, Deserialize)] | #[derive(Clone, Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| pub struct AppendAction { | pub struct AppendAction { | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |     #[serde_as(as = "VecLogError<_>")] | ||||||
|     pub continuation_items: Vec<VideoListItem<PlaylistVideo>>, |     pub continuation_items: MapResult<Vec<VideoListItem<PlaylistVideo>>>, | ||||||
|     pub target_id: String, |     pub target_id: String, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,3 +1,5 @@ | ||||||
|  | #![allow(clippy::enum_variant_names)] | ||||||
|  | 
 | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serde_with::serde_as; | use serde_with::serde_as; | ||||||
| use serde_with::{DefaultOnError, VecSkipError}; | use serde_with::{DefaultOnError, VecSkipError}; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/player.rs | source: src/client/player.rs | ||||||
| expression: player_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| info: | info: | ||||||
|   id: pPvd8UxmSbQ |   id: pPvd8UxmSbQ | ||||||
|  | @ -184,6 +184,22 @@ video_only_streams: | ||||||
|     format: mp4 |     format: mp4 | ||||||
|     codec: av01 |     codec: av01 | ||||||
|     throttled: false |     throttled: false | ||||||
|  |   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&dur=163.096&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=22&lmt=1580005750956837&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&pl=37&ratebypass=yes&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgFlQZgR63Yz9UgY9gVqiyGDVkZmSmACRP3-MmKN7CRzQCIAMHAwZbHmWL1qNH4Nu3A0pXZwErXMVPzMIt-PyxeZqa&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cratebypass%2Cdur%2Clmt&txp=2211222&vprv=1" | ||||||
|  |     itag: 22 | ||||||
|  |     bitrate: 1574434 | ||||||
|  |     average_bitrate: 1574434 | ||||||
|  |     size: ~ | ||||||
|  |     index_range: ~ | ||||||
|  |     init_range: ~ | ||||||
|  |     width: 1280 | ||||||
|  |     height: 720 | ||||||
|  |     fps: 30 | ||||||
|  |     quality: 720p | ||||||
|  |     hdr: false | ||||||
|  |     mime: "video/mp4; codecs=\"avc1.64001F, mp4a.40.2\"" | ||||||
|  |     format: mp4 | ||||||
|  |     codec: avc1 | ||||||
|  |     throttled: false | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=22365208&dur=163.046&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=398&keepalive=yes&lmt=1608048380553749&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgR6KqCOoig_FMl2tWKa7qHSmCjIZa9S7ABzEI16qdO2sCIFXccwql4bqV9CHlqXY4tgxyMFUsp7vW4XUjxs3AyG6H&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=22365208&dur=163.046&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=398&keepalive=yes&lmt=1608048380553749&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgR6KqCOoig_FMl2tWKa7qHSmCjIZa9S7ABzEI16qdO2sCIFXccwql4bqV9CHlqXY4tgxyMFUsp7vW4XUjxs3AyG6H&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" | ||||||
|     itag: 398 |     itag: 398 | ||||||
|     bitrate: 1348419 |     bitrate: 1348419 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/player.rs | source: src/client/player.rs | ||||||
| expression: player_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| info: | info: | ||||||
|   id: pPvd8UxmSbQ |   id: pPvd8UxmSbQ | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/player.rs | source: src/client/player.rs | ||||||
| expression: player_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| info: | info: | ||||||
|   id: pPvd8UxmSbQ |   id: pPvd8UxmSbQ | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/player.rs | source: src/client/player.rs | ||||||
| expression: player_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| info: | info: | ||||||
|   id: pPvd8UxmSbQ |   id: pPvd8UxmSbQ | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/player.rs | source: src/client/player.rs | ||||||
| expression: player_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| info: | info: | ||||||
|   id: pPvd8UxmSbQ |   id: pPvd8UxmSbQ | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/playlist.rs | source: src/client/playlist.rs | ||||||
| expression: playlist_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| id: PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ | id: PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ | ||||||
| name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022 | name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/playlist.rs | source: src/client/playlist.rs | ||||||
| expression: playlist_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| id: PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe | id: PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe | ||||||
| name: Minecraft SHINE | name: Minecraft SHINE | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| --- | --- | ||||||
| source: src/client/playlist.rs | source: src/client/playlist.rs | ||||||
| expression: playlist_data | expression: map_res.c | ||||||
| --- | --- | ||||||
| id: RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk | id: RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk | ||||||
| name: Easy Pop | name: Easy Pop | ||||||
|  |  | ||||||
|  | @ -1,111 +0,0 @@ | ||||||
| use anyhow::Result; |  | ||||||
| use reqwest::Method; |  | ||||||
| use serde::Serialize; |  | ||||||
| 
 |  | ||||||
| use super::{response, ClientType, ContextYT, RustyTube}; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| struct QVideo { |  | ||||||
|     context: ContextYT, |  | ||||||
|     /// YouTube video ID
 |  | ||||||
|     video_id: String, |  | ||||||
|     /// Set to true to allow extraction of streams with sensitive content
 |  | ||||||
|     content_check_ok: bool, |  | ||||||
|     /// Probably refers to allowing sensitive content, too
 |  | ||||||
|     racy_check_ok: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| struct QVideoCont { |  | ||||||
|     context: ContextYT, |  | ||||||
|     continuation: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl RustyTube { |  | ||||||
|     async fn get_video_response(&self, video_id: &str) -> Result<response::Video> { |  | ||||||
|         let client = self.get_ytclient(ClientType::Desktop); |  | ||||||
|         let context = client.get_context(true).await; |  | ||||||
|         let request_body = QVideo { |  | ||||||
|             context, |  | ||||||
|             video_id: video_id.to_owned(), |  | ||||||
|             content_check_ok: true, |  | ||||||
|             racy_check_ok: true, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let resp = client |  | ||||||
|             .request_builder(Method::POST, "next") |  | ||||||
|             .await |  | ||||||
|             .json(&request_body) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status()?; |  | ||||||
| 
 |  | ||||||
|         Ok(resp.json::<response::Video>().await?) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async fn get_comments_response(&self, ctoken: &str) -> Result<response::VideoComments> { |  | ||||||
|         let client = self.get_ytclient(ClientType::Desktop); |  | ||||||
|         let context = client.get_context(true).await; |  | ||||||
|         let request_body = QVideoCont { |  | ||||||
|             context, |  | ||||||
|             continuation: ctoken.to_owned(), |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let resp = client |  | ||||||
|             .request_builder(Method::POST, "next") |  | ||||||
|             .await |  | ||||||
|             .json(&request_body) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status()?; |  | ||||||
| 
 |  | ||||||
|         Ok(resp.json::<response::VideoComments>().await?) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async fn get_recommendations_response( |  | ||||||
|         &self, |  | ||||||
|         ctoken: &str, |  | ||||||
|     ) -> Result<response::VideoRecommendations> { |  | ||||||
|         let client = self.get_ytclient(ClientType::Desktop); |  | ||||||
|         let context = client.get_context(true).await; |  | ||||||
|         let request_body = QVideoCont { |  | ||||||
|             context, |  | ||||||
|             continuation: ctoken.to_owned(), |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let resp = client |  | ||||||
|             .request_builder(Method::POST, "next") |  | ||||||
|             .await |  | ||||||
|             .json(&request_body) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status()?; |  | ||||||
| 
 |  | ||||||
|         Ok(resp.json::<response::VideoRecommendations>().await?) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
|     use super::*; |  | ||||||
| 
 |  | ||||||
|     #[tokio::test] |  | ||||||
|     async fn t_get_video_response() { |  | ||||||
|         let rt = RustyTube::new(); |  | ||||||
|         // rt.get_video("ZeerrnuLi5E").await.unwrap();
 |  | ||||||
|         dbg!(rt.get_video_response("iQfSvIgIs_M").await.unwrap()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[tokio::test] |  | ||||||
|     async fn t_get_comments_response() { |  | ||||||
|         let rt = RustyTube::new(); |  | ||||||
|         // rt.get_comments("Eg0SC2lRZlN2SWdJc19NGAYyJSIRIgtpUWZTdklnSXNfTTAAeAJCEGNvbW1lbnRzLXNlY3Rpb24%3D").await.unwrap();
 |  | ||||||
|         dbg!(rt.get_comments_response("Eg0SC2lRZlN2SWdJc19NGAYychpFEhRVZ2lnVGJVTEZ6Qk5FWGdDb0FFQyICCAAqGFVDWFgwUldPSUJqdDRvM3ppSHUtNmE1QTILaVFmU3ZJZ0lzX01AAUgKQiljb21tZW50LXJlcGxpZXMtaXRlbS1VZ2lnVGJVTEZ6Qk5FWGdDb0FFQw%3D%3D").await.unwrap()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[tokio::test] |  | ||||||
|     async fn t_get_recommendations_response() { |  | ||||||
|         let rt = RustyTube::new(); |  | ||||||
|         dbg!(rt.get_recommendations_response("CBQSExILaVFmU3ZJZ0lzX03AAQHIAQEYACqkBjJzNkw2d3pVQkFyUkJBb0Q4ajRBQ2c3Q1Bnc0lvWXlRejhLZnRZUGNBUW9EOGo0QUNnN0NQZ3NJeElEX2w0YjFtNnUtQVFvRDhqNEFDZzNDUGdvSXg5Ykx3WUNKenFwX0NnUHlQZ0FLRGNJLUNnaW83T2pqZzVPTHZEOEtBX0ktQUFvTndqNEtDTE9venZmQThybVhXd29EOGo0QUNnM0NQZ29JdzZETV9vSFk0cHRCQ2dQeVBnQUtEc0ktQ3dqbW9QbURpcHVPel80QkNnUHlQZ0FLRGNJLUNnalY4THpEazlfOTRCWUtBX0ktQUFvT3dqNExDTXVZNU9YZzE3ejV2d0VLQV9JLUFBb053ajRLQ1A3eHZiSGswTnVuYWdvRDhqNEFDZzdDUGdzSXFQYVU5ZGp2Ml96S0FRb0Q4ajRBQ2c3Q1Bnc0lfSW1acUtQOTlfQ09BUW9EOGo0QUNnM0NQZ29JeGRtNzlZS3prcUFqQ2dQeVBnQUtEY0ktQ2dpZ3FJMkg0UENRX2s0S0FfSS1BQW9Pd2o0TENQV0V5NV9ZeDhERl9nRUtBX0ktQUFvT3dqNExDTzJid3VuV3BPX3ppd0VLQV9JLUFBb2gwajRlQ2h4U1JFTk5WVU5ZV0RCU1YwOUpRbXAwTkc4emVtbElkUzAyWVRWQkNnUHlQZ0FLRGNJLUNnaXpqcXZwcDh5MWwwMEtBX0ktQUFvTndqNEtDTFhWbl83dHhfWDJOUW9EOGo0QUNnN0NQZ3NJNWR5ZWc1NjZyUGUwQVJJVUFBSUVCZ2dLREE0UUVoUVdHQm9jSGlBaUpDWWFCQWdBRUFFYUJBZ0NFQU1hQkFnRUVBVWFCQWdHRUFjYUJBZ0lFQWthQkFnS0VBc2FCQWdNRUEwYUJBZ09FQThhQkFnUUVCRWFCQWdTRUJNYUJBZ1VFQlVhQkFnV0VCY2FCQWdZRUJrYUJBZ2FFQnNhQkFnY0VCMGFCQWdlRUI4YUJBZ2dFQ0VhQkFnaUVDTWFCQWdrRUNVYUJBZ21FQ2NxRkFBQ0JBWUlDZ3dPRUJJVUZoZ2FIQjRnSWlRbWoPd2F0Y2gtbmV4dC1mZWVk").await.unwrap()); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,534 +0,0 @@ | ||||||
| pub mod player; |  | ||||||
| pub mod playlist; |  | ||||||
| 
 |  | ||||||
| mod response; |  | ||||||
| 
 |  | ||||||
| use std::fmt::Debug; |  | ||||||
| use std::sync::Arc; |  | ||||||
| 
 |  | ||||||
| use anyhow::{anyhow, Context, Result}; |  | ||||||
| use fancy_regex::Regex; |  | ||||||
| use once_cell::sync::Lazy; |  | ||||||
| use rand::Rng; |  | ||||||
| use reqwest::{header, Client, ClientBuilder, Method, RequestBuilder}; |  | ||||||
| use serde::{de::DeserializeOwned, Deserialize, Serialize}; |  | ||||||
| 
 |  | ||||||
| use crate::{ |  | ||||||
|     cache::Cache, |  | ||||||
|     deobfuscate::Deobfuscator, |  | ||||||
|     model::{Country, Language}, |  | ||||||
|     report::{Level, Report, Reporter, YamlFileReporter}, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] |  | ||||||
| #[serde(rename_all = "snake_case")] |  | ||||||
| pub enum ClientType { |  | ||||||
|     Desktop, |  | ||||||
|     DesktopMusic, |  | ||||||
|     TvHtml5Embed, |  | ||||||
|     Android, |  | ||||||
|     Ios, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const CLIENT_TYPES: [ClientType; 5] = [ |  | ||||||
|     ClientType::Desktop, |  | ||||||
|     ClientType::DesktopMusic, |  | ||||||
|     ClientType::TvHtml5Embed, |  | ||||||
|     ClientType::Android, |  | ||||||
|     ClientType::Ios, |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| impl ClientType { |  | ||||||
|     fn is_web(&self) -> bool { |  | ||||||
|         match self { |  | ||||||
|             ClientType::Desktop | ClientType::DesktopMusic | ClientType::TvHtml5Embed => true, |  | ||||||
|             ClientType::Android | ClientType::Ios => false, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContextYT { |  | ||||||
|     client: ClientInfo, |  | ||||||
|     /// only used on desktop
 |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     request: Option<RequestYT>, |  | ||||||
|     user: User, |  | ||||||
|     /// only used for the embedded player
 |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     third_party: Option<ThirdParty>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct ClientInfo { |  | ||||||
|     client_name: String, |  | ||||||
|     client_version: String, |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     client_screen: Option<String>, |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     device_model: Option<String>, |  | ||||||
|     platform: String, |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     original_url: Option<String>, |  | ||||||
|     hl: Language, |  | ||||||
|     gl: Country, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct RequestYT { |  | ||||||
|     internal_experiment_flags: Vec<String>, |  | ||||||
|     use_ssl: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl Default for RequestYT { |  | ||||||
|     fn default() -> Self { |  | ||||||
|         Self { |  | ||||||
|             internal_experiment_flags: vec![], |  | ||||||
|             use_ssl: true, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize, Default)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct User { |  | ||||||
|     // TO DO: provide a way to enable restricted mode with:
 |  | ||||||
|     // "enableSafetyMode": true
 |  | ||||||
|     locked_safety_mode: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct ThirdParty { |  | ||||||
|     embed_url: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const DEFAULT_UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0"; |  | ||||||
| 
 |  | ||||||
| const CONSENT_COOKIE: &str = "CONSENT"; |  | ||||||
| const CONSENT_COOKIE_YES: &str = "YES+yt.462272069.de+FX+"; |  | ||||||
| 
 |  | ||||||
| const YOUTUBEI_V1_URL: &str = "https://www.youtube.com/youtubei/v1/"; |  | ||||||
| const YOUTUBEI_V1_GAPIS_URL: &str = "https://youtubei.googleapis.com/youtubei/v1/"; |  | ||||||
| const YOUTUBE_MUSIC_V1_URL: &str = "https://music.youtube.com/youtubei/v1/"; |  | ||||||
| 
 |  | ||||||
| const DISABLE_PRETTY_PRINT_PARAMETER: &str = "&prettyPrint=false"; |  | ||||||
| 
 |  | ||||||
| const DESKTOP_CLIENT_VERSION: &str = "2.20220909.00.00"; |  | ||||||
| const DESKTOP_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; |  | ||||||
| const TVHTML5_CLIENT_VERSION: &str = "2.0"; |  | ||||||
| const DESKTOP_MUSIC_API_KEY: &str = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"; |  | ||||||
| const DESKTOP_MUSIC_CLIENT_VERSION: &str = "1.20220831.01.02"; |  | ||||||
| 
 |  | ||||||
| const MOBILE_CLIENT_VERSION: &str = "17.29.35"; |  | ||||||
| const ANDROID_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w"; |  | ||||||
| const IOS_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc"; |  | ||||||
| const IOS_DEVICE_MODEL: &str = "iPhone14,5"; |  | ||||||
| 
 |  | ||||||
| static CLIENT_VERSION_REGEXES: Lazy<[Regex; 1]> = |  | ||||||
|     Lazy::new(|| [Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap()]); |  | ||||||
| 
 |  | ||||||
| #[derive(Clone)] |  | ||||||
| pub struct RustyPipe { |  | ||||||
|     inner: Arc<RustyPipeRef>, |  | ||||||
|     opts: RustyPipeOpts, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| struct RustyPipeRef { |  | ||||||
|     http: Client, |  | ||||||
|     cache: Cache, |  | ||||||
|     reporter: Option<Box<dyn Reporter>>, |  | ||||||
|     user_agent: String, |  | ||||||
|     consent_cookie: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone)] |  | ||||||
| struct RustyPipeOpts { |  | ||||||
|     lang: Language, |  | ||||||
|     country: Country, |  | ||||||
|     report: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl Default for RustyPipe { |  | ||||||
|     fn default() -> Self { |  | ||||||
|         Self::new( |  | ||||||
|             Some(Cache::from_json_file("RustyPipeCache.json")), |  | ||||||
|             Some(Box::new(YamlFileReporter::default())), |  | ||||||
|             None, |  | ||||||
|         ) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl Default for RustyPipeOpts { |  | ||||||
|     fn default() -> Self { |  | ||||||
|         Self { |  | ||||||
|             lang: Language::En, |  | ||||||
|             country: Country::Us, |  | ||||||
|             report: false, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl RustyPipe { |  | ||||||
|     pub fn new( |  | ||||||
|         cache: Option<Cache>, |  | ||||||
|         reporter: Option<Box<dyn Reporter>>, |  | ||||||
|         user_agent: Option<String>, |  | ||||||
|     ) -> Self { |  | ||||||
|         let cache = cache.unwrap_or_else(|| Cache::default()); |  | ||||||
|         let user_agent = user_agent.unwrap_or(DEFAULT_UA.to_owned()); |  | ||||||
| 
 |  | ||||||
|         let http = ClientBuilder::new() |  | ||||||
|             .user_agent(user_agent.to_owned()) |  | ||||||
|             .gzip(true) |  | ||||||
|             .brotli(true) |  | ||||||
|             .build() |  | ||||||
|             .expect("unable to build the HTTP client"); |  | ||||||
| 
 |  | ||||||
|         RustyPipe { |  | ||||||
|             inner: Arc::new(RustyPipeRef { |  | ||||||
|                 http, |  | ||||||
|                 cache, |  | ||||||
|                 reporter, |  | ||||||
|                 user_agent, |  | ||||||
|                 consent_cookie: format!( |  | ||||||
|                     "{}={}{}", |  | ||||||
|                     CONSENT_COOKIE, |  | ||||||
|                     CONSENT_COOKIE_YES, |  | ||||||
|                     rand::thread_rng().gen_range(100..1000) |  | ||||||
|                 ), |  | ||||||
|             }), |  | ||||||
|             opts: RustyPipeOpts::default(), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn lang(mut self, lang: Language) -> Self { |  | ||||||
|         self.opts.lang = lang; |  | ||||||
|         self |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn country(mut self, country: Country) -> Self { |  | ||||||
|         self.opts.country = country; |  | ||||||
|         self |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn report(mut self, report: bool) -> Self { |  | ||||||
|         self.opts.report = report; |  | ||||||
|         self |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async fn get_context(&self, ctype: ClientType, localized: bool) -> ContextYT { |  | ||||||
|         let hl = match localized { |  | ||||||
|             true => self.opts.lang, |  | ||||||
|             false => Language::En, |  | ||||||
|         }; |  | ||||||
|         let gl = match localized { |  | ||||||
|             true => self.opts.country, |  | ||||||
|             false => Country::Us, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         match ctype { |  | ||||||
|             ClientType::Desktop => ContextYT { |  | ||||||
|                 client: ClientInfo { |  | ||||||
|                     client_name: "WEB".to_owned(), |  | ||||||
|                     client_version: DESKTOP_CLIENT_VERSION.to_owned(), |  | ||||||
|                     client_screen: None, |  | ||||||
|                     device_model: None, |  | ||||||
|                     platform: "DESKTOP".to_owned(), |  | ||||||
|                     original_url: Some("https://www.youtube.com/".to_owned()), |  | ||||||
|                     hl, |  | ||||||
|                     gl, |  | ||||||
|                 }, |  | ||||||
|                 request: Some(RequestYT::default()), |  | ||||||
|                 user: User::default(), |  | ||||||
|                 third_party: None, |  | ||||||
|             }, |  | ||||||
|             ClientType::DesktopMusic => ContextYT { |  | ||||||
|                 client: ClientInfo { |  | ||||||
|                     client_name: "WEB_REMIX".to_owned(), |  | ||||||
|                     client_version: DESKTOP_MUSIC_CLIENT_VERSION.to_owned(), |  | ||||||
|                     client_screen: None, |  | ||||||
|                     device_model: None, |  | ||||||
|                     platform: "DESKTOP".to_owned(), |  | ||||||
|                     original_url: Some("https://music.youtube.com/".to_owned()), |  | ||||||
|                     hl, |  | ||||||
|                     gl, |  | ||||||
|                 }, |  | ||||||
|                 request: Some(RequestYT::default()), |  | ||||||
|                 user: User::default(), |  | ||||||
|                 third_party: None, |  | ||||||
|             }, |  | ||||||
|             ClientType::TvHtml5Embed => ContextYT { |  | ||||||
|                 client: ClientInfo { |  | ||||||
|                     client_name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER".to_owned(), |  | ||||||
|                     client_version: TVHTML5_CLIENT_VERSION.to_owned(), |  | ||||||
|                     client_screen: Some("EMBED".to_owned()), |  | ||||||
|                     device_model: None, |  | ||||||
|                     platform: "TV".to_owned(), |  | ||||||
|                     original_url: None, |  | ||||||
|                     hl, |  | ||||||
|                     gl, |  | ||||||
|                 }, |  | ||||||
|                 request: Some(RequestYT::default()), |  | ||||||
|                 user: User::default(), |  | ||||||
|                 third_party: Some(ThirdParty { |  | ||||||
|                     embed_url: "https://www.youtube.com/".to_owned(), |  | ||||||
|                 }), |  | ||||||
|             }, |  | ||||||
|             ClientType::Android => ContextYT { |  | ||||||
|                 client: ClientInfo { |  | ||||||
|                     client_name: "ANDROID".to_owned(), |  | ||||||
|                     client_version: MOBILE_CLIENT_VERSION.to_owned(), |  | ||||||
|                     client_screen: None, |  | ||||||
|                     device_model: None, |  | ||||||
|                     platform: "MOBILE".to_owned(), |  | ||||||
|                     original_url: None, |  | ||||||
|                     hl, |  | ||||||
|                     gl, |  | ||||||
|                 }, |  | ||||||
|                 request: None, |  | ||||||
|                 user: User::default(), |  | ||||||
|                 third_party: None, |  | ||||||
|             }, |  | ||||||
|             ClientType::Ios => ContextYT { |  | ||||||
|                 client: ClientInfo { |  | ||||||
|                     client_name: "IOS".to_owned(), |  | ||||||
|                     client_version: MOBILE_CLIENT_VERSION.to_owned(), |  | ||||||
|                     client_screen: None, |  | ||||||
|                     device_model: Some(IOS_DEVICE_MODEL.to_owned()), |  | ||||||
|                     platform: "MOBILE".to_owned(), |  | ||||||
|                     original_url: None, |  | ||||||
|                     hl, |  | ||||||
|                     gl, |  | ||||||
|                 }, |  | ||||||
|                 request: None, |  | ||||||
|                 user: User::default(), |  | ||||||
|                 third_party: None, |  | ||||||
|             }, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async fn request_builder( |  | ||||||
|         &self, |  | ||||||
|         ctype: ClientType, |  | ||||||
|         method: Method, |  | ||||||
|         endpoint: &str, |  | ||||||
|     ) -> RequestBuilder { |  | ||||||
|         match ctype { |  | ||||||
|             ClientType::Desktop => self |  | ||||||
|                 .inner |  | ||||||
|                 .http |  | ||||||
|                 .request( |  | ||||||
|                     method, |  | ||||||
|                     format!( |  | ||||||
|                         "{}{}?key={}{}", |  | ||||||
|                         YOUTUBEI_V1_URL, endpoint, DESKTOP_API_KEY, DISABLE_PRETTY_PRINT_PARAMETER |  | ||||||
|                     ), |  | ||||||
|                 ) |  | ||||||
|                 .header(header::ORIGIN, "https://www.youtube.com") |  | ||||||
|                 .header(header::REFERER, "https://www.youtube.com") |  | ||||||
|                 .header(header::COOKIE, self.inner.consent_cookie.to_owned()) |  | ||||||
|                 .header("X-YouTube-Client-Name", "1") |  | ||||||
|                 .header("X-YouTube-Client-Version", DESKTOP_CLIENT_VERSION), |  | ||||||
|             ClientType::DesktopMusic => self |  | ||||||
|                 .inner |  | ||||||
|                 .http |  | ||||||
|                 .request( |  | ||||||
|                     method, |  | ||||||
|                     format!( |  | ||||||
|                         "{}{}?key={}{}", |  | ||||||
|                         YOUTUBE_MUSIC_V1_URL, |  | ||||||
|                         endpoint, |  | ||||||
|                         DESKTOP_MUSIC_API_KEY, |  | ||||||
|                         DISABLE_PRETTY_PRINT_PARAMETER |  | ||||||
|                     ), |  | ||||||
|                 ) |  | ||||||
|                 .header(header::ORIGIN, "https://music.youtube.com") |  | ||||||
|                 .header(header::REFERER, "https://music.youtube.com") |  | ||||||
|                 .header(header::COOKIE, self.inner.consent_cookie.to_owned()) |  | ||||||
|                 .header("X-YouTube-Client-Name", "67") |  | ||||||
|                 .header("X-YouTube-Client-Version", DESKTOP_MUSIC_CLIENT_VERSION), |  | ||||||
|             ClientType::TvHtml5Embed => self |  | ||||||
|                 .inner |  | ||||||
|                 .http |  | ||||||
|                 .request( |  | ||||||
|                     method, |  | ||||||
|                     format!( |  | ||||||
|                         "{}{}?key={}{}", |  | ||||||
|                         YOUTUBEI_V1_URL, endpoint, DESKTOP_API_KEY, DISABLE_PRETTY_PRINT_PARAMETER |  | ||||||
|                     ), |  | ||||||
|                 ) |  | ||||||
|                 .header(header::ORIGIN, "https://www.youtube.com") |  | ||||||
|                 .header(header::REFERER, "https://www.youtube.com") |  | ||||||
|                 .header("X-YouTube-Client-Name", "1") |  | ||||||
|                 .header("X-YouTube-Client-Version", TVHTML5_CLIENT_VERSION), |  | ||||||
|             ClientType::Android => self |  | ||||||
|                 .inner |  | ||||||
|                 .http |  | ||||||
|                 .request( |  | ||||||
|                     method, |  | ||||||
|                     format!( |  | ||||||
|                         "{}{}?key={}{}", |  | ||||||
|                         YOUTUBEI_V1_GAPIS_URL, |  | ||||||
|                         endpoint, |  | ||||||
|                         ANDROID_API_KEY, |  | ||||||
|                         DISABLE_PRETTY_PRINT_PARAMETER |  | ||||||
|                     ), |  | ||||||
|                 ) |  | ||||||
|                 .header( |  | ||||||
|                     header::USER_AGENT, |  | ||||||
|                     format!( |  | ||||||
|                         "com.google.android.youtube/{} (Linux; U; Android 12; {}) gzip", |  | ||||||
|                         MOBILE_CLIENT_VERSION, self.opts.country |  | ||||||
|                     ), |  | ||||||
|                 ) |  | ||||||
|                 .header("X-Goog-Api-Format-Version", "2"), |  | ||||||
|             ClientType::Ios => self |  | ||||||
|                 .inner |  | ||||||
|                 .http |  | ||||||
|                 .request( |  | ||||||
|                     method, |  | ||||||
|                     format!( |  | ||||||
|                         "{}{}?key={}{}", |  | ||||||
|                         YOUTUBEI_V1_GAPIS_URL, |  | ||||||
|                         endpoint, |  | ||||||
|                         IOS_API_KEY, |  | ||||||
|                         DISABLE_PRETTY_PRINT_PARAMETER |  | ||||||
|                     ), |  | ||||||
|                 ) |  | ||||||
|                 .header( |  | ||||||
|                     header::USER_AGENT, |  | ||||||
|                     format!( |  | ||||||
|                         "com.google.ios.youtube/{} ({}; U; CPU iOS 15_4 like Mac OS X; {})", |  | ||||||
|                         MOBILE_CLIENT_VERSION, IOS_DEVICE_MODEL, self.opts.country |  | ||||||
|                     ), |  | ||||||
|                 ) |  | ||||||
|                 .header("X-Goog-Api-Format-Version", "2"), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async fn execute_request< |  | ||||||
|         R: DeserializeOwned + MapResponse<M> + Debug, |  | ||||||
|         M, |  | ||||||
|         B: Serialize + ?Sized, |  | ||||||
|     >( |  | ||||||
|         &self, |  | ||||||
|         ctype: ClientType, |  | ||||||
|         operation: &str, |  | ||||||
|         method: Method, |  | ||||||
|         endpoint: &str, |  | ||||||
|         id: &str, |  | ||||||
|         body: &B, |  | ||||||
|         deobf: Option<&Deobfuscator>, |  | ||||||
|     ) -> Result<M> { |  | ||||||
|         let request = self |  | ||||||
|             .request_builder(ctype, method.clone(), endpoint) |  | ||||||
|             .await |  | ||||||
|             .json(body) |  | ||||||
|             .build()?; |  | ||||||
| 
 |  | ||||||
|         let request_url = request.url().to_string(); |  | ||||||
|         let request_headers = request.headers().to_owned(); |  | ||||||
| 
 |  | ||||||
|         let response = self.inner.http.execute(request).await?; |  | ||||||
| 
 |  | ||||||
|         let status = response.status(); |  | ||||||
|         let resp_str = response.text().await?; |  | ||||||
| 
 |  | ||||||
|         let create_report = |level: Level, error: Option<String>, msgs: Vec<String>| { |  | ||||||
|             if let Some(reporter) = &self.inner.reporter { |  | ||||||
|                 let report = Report { |  | ||||||
|                     package: "rustypipe".to_owned(), |  | ||||||
|                     version: "0.1.0".to_owned(), |  | ||||||
|                     date: chrono::Local::now(), |  | ||||||
|                     level, |  | ||||||
|                     operation: operation.to_owned(), |  | ||||||
|                     error, |  | ||||||
|                     msgs, |  | ||||||
|                     http_request: crate::report::HTTPRequest { |  | ||||||
|                         url: request_url, |  | ||||||
|                         method: method.to_string(), |  | ||||||
|                         req_header: request_headers |  | ||||||
|                             .iter() |  | ||||||
|                             .map(|(k, v)| { |  | ||||||
|                                 (k.to_string(), v.to_str().unwrap_or_default().to_owned()) |  | ||||||
|                             }) |  | ||||||
|                             .collect(), |  | ||||||
|                         req_body: serde_json::to_string(body).unwrap_or_default(), |  | ||||||
|                         status: status.into(), |  | ||||||
|                         resp_body: resp_str.to_owned(), |  | ||||||
|                     }, |  | ||||||
|                 }; |  | ||||||
| 
 |  | ||||||
|                 reporter.report(&report); |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         if status.is_client_error() || status.is_server_error() { |  | ||||||
|             let e = anyhow!("Server responded with error code {}", status); |  | ||||||
|             create_report(Level::ERR, Some(e.to_string()), vec![]); |  | ||||||
|             return Err(e); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         match serde_json::from_str::<R>(&resp_str) { |  | ||||||
|             Ok(deserialized) => match deserialized.map_response(id, self.opts.lang, deobf) { |  | ||||||
|                 Ok(mapres) => { |  | ||||||
|                     if !mapres.warnings.is_empty() { |  | ||||||
|                         create_report( |  | ||||||
|                             Level::WRN, |  | ||||||
|                             Some("Warnings during deserialization/mapping".to_owned()), |  | ||||||
|                             mapres.warnings, |  | ||||||
|                         ); |  | ||||||
|                     } else if self.opts.report { |  | ||||||
|                         create_report(Level::DBG, None, vec![]); |  | ||||||
|                     } |  | ||||||
|                     Ok(mapres.c) |  | ||||||
|                 } |  | ||||||
|                 Err(e) => { |  | ||||||
|                     let emsg = "Could not map reponse"; |  | ||||||
|                     create_report(Level::ERR, Some(emsg.to_owned()), vec![e.to_string()]); |  | ||||||
|                     Err(e).context(emsg) |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             Err(e) => { |  | ||||||
|                 let emsg = "Could not deserialize response"; |  | ||||||
|                 create_report(Level::ERR, Some(emsg.to_owned()), vec![e.to_string()]); |  | ||||||
|                 Err(e).context(emsg) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| trait MapResponse<T> { |  | ||||||
|     fn map_response( |  | ||||||
|         self, |  | ||||||
|         id: &str, |  | ||||||
|         lang: Language, |  | ||||||
|         deobf: Option<&Deobfuscator>, |  | ||||||
|     ) -> Result<MapResult<T>>; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone)] |  | ||||||
| pub struct MapResult<T> { |  | ||||||
|     pub c: T, |  | ||||||
|     pub warnings: Vec<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl<T> Debug for MapResult<T> |  | ||||||
| where |  | ||||||
|     T: Debug, |  | ||||||
| { |  | ||||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |  | ||||||
|         self.c.fmt(f) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /* |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
|     use super::*; |  | ||||||
| } |  | ||||||
| */ |  | ||||||
|  | @ -1,807 +0,0 @@ | ||||||
| use std::{ |  | ||||||
|     borrow::Cow, |  | ||||||
|     collections::{BTreeMap, HashMap}, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| use anyhow::{anyhow, bail, Result}; |  | ||||||
| use chrono::{Local, NaiveDateTime, NaiveTime, TimeZone}; |  | ||||||
| use fancy_regex::Regex; |  | ||||||
| use once_cell::sync::Lazy; |  | ||||||
| use reqwest::{Method, Url}; |  | ||||||
| use serde::Serialize; |  | ||||||
| 
 |  | ||||||
| use crate::{ |  | ||||||
|     deobfuscate::Deobfuscator, |  | ||||||
|     model::{ |  | ||||||
|         AudioCodec, AudioFormat, AudioStream, AudioTrack, Channel, Language, Subtitle, VideoCodec, |  | ||||||
|         VideoFormat, VideoInfo, VideoPlayer, VideoStream, |  | ||||||
|     }, |  | ||||||
|     util, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| use super::{ |  | ||||||
|     response::{self, player}, |  | ||||||
|     ClientType, ContextYT, MapResponse, MapResult, RustyPipe, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct QPlayer { |  | ||||||
|     context: ContextYT, |  | ||||||
|     /// Website playback context
 |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     playback_context: Option<QPlaybackContext>, |  | ||||||
|     /// Content playback nonce (mobile only, 16 random chars)
 |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     cpn: Option<String>, |  | ||||||
|     /// YouTube video ID
 |  | ||||||
|     video_id: String, |  | ||||||
|     /// Set to true to allow extraction of streams with sensitive content
 |  | ||||||
|     content_check_ok: bool, |  | ||||||
|     /// Probably refers to allowing sensitive content, too
 |  | ||||||
|     racy_check_ok: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct QPlaybackContext { |  | ||||||
|     content_playback_context: QContentPlaybackContext, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct QContentPlaybackContext { |  | ||||||
|     /// Signature timestamp extracted from player.js
 |  | ||||||
|     signature_timestamp: String, |  | ||||||
|     /// Referer URL from website
 |  | ||||||
|     referer: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl RustyPipe { |  | ||||||
|     pub async fn get_player(&self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> { |  | ||||||
|         let (context, deobf) = tokio::join!( |  | ||||||
|             self.get_context(client_type, false), |  | ||||||
|             Deobfuscator::from_fetched_info(self.inner.http.clone(), self.inner.cache.clone()) |  | ||||||
|         ); |  | ||||||
|         let deobf = deobf?; |  | ||||||
| 
 |  | ||||||
|         let request_body = if client_type.is_web() { |  | ||||||
|             QPlayer { |  | ||||||
|                 context, |  | ||||||
|                 playback_context: Some(QPlaybackContext { |  | ||||||
|                     content_playback_context: QContentPlaybackContext { |  | ||||||
|                         signature_timestamp: deobf.get_sts(), |  | ||||||
|                         referer: format!("https://www.youtube.com/watch?v={}", video_id), |  | ||||||
|                     }, |  | ||||||
|                 }), |  | ||||||
|                 cpn: None, |  | ||||||
|                 video_id: video_id.to_owned(), |  | ||||||
|                 content_check_ok: true, |  | ||||||
|                 racy_check_ok: true, |  | ||||||
|             } |  | ||||||
|         } else { |  | ||||||
|             QPlayer { |  | ||||||
|                 context, |  | ||||||
|                 playback_context: None, |  | ||||||
|                 cpn: Some(util::generate_content_playback_nonce()), |  | ||||||
|                 video_id: video_id.to_owned(), |  | ||||||
|                 content_check_ok: true, |  | ||||||
|                 racy_check_ok: true, |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         self.execute_request::<response::Player, _, _>( |  | ||||||
|             client_type, |  | ||||||
|             "get_player", |  | ||||||
|             Method::POST, |  | ||||||
|             "player", |  | ||||||
|             video_id, |  | ||||||
|             &request_body, |  | ||||||
|             Some(&deobf), |  | ||||||
|         ) |  | ||||||
|         .await |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl MapResponse<VideoPlayer> for response::Player { |  | ||||||
|     fn map_response( |  | ||||||
|         self, |  | ||||||
|         id: &str, |  | ||||||
|         _lang: Language, |  | ||||||
|         deobf: Option<&Deobfuscator>, |  | ||||||
|     ) -> Result<super::MapResult<VideoPlayer>> { |  | ||||||
|         let deobf = deobf.unwrap(); |  | ||||||
|         let mut warnings = vec![]; |  | ||||||
| 
 |  | ||||||
|         // Check playability status
 |  | ||||||
|         match self.playability_status { |  | ||||||
|             response::player::PlayabilityStatus::Ok { live_streamability } => { |  | ||||||
|                 if live_streamability.is_some() { |  | ||||||
|                     bail!("Active livestreams are not supported") |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             response::player::PlayabilityStatus::Unplayable { reason } => { |  | ||||||
|                 bail!("Video is unplayable. Reason: {}", reason) |  | ||||||
|             } |  | ||||||
|             response::player::PlayabilityStatus::LoginRequired { reason } => { |  | ||||||
|                 bail!("Playback requires login. Reason: {}", reason) |  | ||||||
|             } |  | ||||||
|             response::player::PlayabilityStatus::LiveStreamOffline { reason } => { |  | ||||||
|                 bail!("Livestream is offline. Reason: {}", reason) |  | ||||||
|             } |  | ||||||
|             response::player::PlayabilityStatus::Error { reason } => { |  | ||||||
|                 bail!("Video was deleted. Reason: {}", reason) |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let mut streaming_data = some_or_bail!( |  | ||||||
|             self.streaming_data, |  | ||||||
|             Err(anyhow!("No streaming data was returned")) |  | ||||||
|         ); |  | ||||||
|         let video_details = some_or_bail!( |  | ||||||
|             self.video_details, |  | ||||||
|             Err(anyhow!("No video details were returned")) |  | ||||||
|         ); |  | ||||||
|         let microformat = self.microformat.map(|m| m.player_microformat_renderer); |  | ||||||
|         let (publish_date, category, tags, is_family_safe) = |  | ||||||
|             microformat.map_or((None, None, None, None), |m| { |  | ||||||
|                 ( |  | ||||||
|                     Local |  | ||||||
|                         .from_local_datetime(&NaiveDateTime::new( |  | ||||||
|                             m.publish_date, |  | ||||||
|                             NaiveTime::from_hms(0, 0, 0), |  | ||||||
|                         )) |  | ||||||
|                         .single(), |  | ||||||
|                     Some(m.category), |  | ||||||
|                     m.tags, |  | ||||||
|                     Some(m.is_family_safe), |  | ||||||
|                 ) |  | ||||||
|             }); |  | ||||||
| 
 |  | ||||||
|         if video_details.video_id != id { |  | ||||||
|             bail!( |  | ||||||
|                 "got wrong video id {}, expected {}", |  | ||||||
|                 video_details.video_id, |  | ||||||
|                 id |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let video_info = VideoInfo { |  | ||||||
|             id: video_details.video_id, |  | ||||||
|             title: video_details.title, |  | ||||||
|             description: video_details.short_description, |  | ||||||
|             length: video_details.length_seconds, |  | ||||||
|             thumbnails: video_details.thumbnail.unwrap_or_default().into(), |  | ||||||
|             channel: Channel { |  | ||||||
|                 id: video_details.channel_id, |  | ||||||
|                 name: video_details.author, |  | ||||||
|             }, |  | ||||||
|             publish_date, |  | ||||||
|             view_count: video_details.view_count, |  | ||||||
|             keywords: match video_details.keywords { |  | ||||||
|                 Some(keywords) => keywords, |  | ||||||
|                 None => tags.unwrap_or_default(), |  | ||||||
|             }, |  | ||||||
|             category, |  | ||||||
|             is_live_content: video_details.is_live_content, |  | ||||||
|             is_family_safe, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let mut formats = streaming_data.formats; |  | ||||||
|         formats.append(&mut streaming_data.adaptive_formats); |  | ||||||
| 
 |  | ||||||
|         let mut last_nsig: [String; 2] = ["".to_owned(), "".to_owned()]; |  | ||||||
| 
 |  | ||||||
|         let mut video_streams: Vec<VideoStream> = Vec::new(); |  | ||||||
|         let mut video_only_streams: Vec<VideoStream> = Vec::new(); |  | ||||||
|         let mut audio_streams: Vec<AudioStream> = Vec::new(); |  | ||||||
| 
 |  | ||||||
|         for f in formats { |  | ||||||
|             if f.format_type == player::FormatType::FormatStreamTypeOtf { |  | ||||||
|                 continue; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             match (f.is_video(), f.is_audio()) { |  | ||||||
|                 (true, true) => { |  | ||||||
|                     let mut map_res = map_video_stream(f, deobf, &mut last_nsig); |  | ||||||
|                     warnings.append(&mut map_res.warnings); |  | ||||||
|                     if let Some(c) = map_res.c { |  | ||||||
|                         video_streams.push(c); |  | ||||||
|                     }; |  | ||||||
|                 } |  | ||||||
|                 (true, false) => { |  | ||||||
|                     let mut map_res = map_video_stream(f, deobf, &mut last_nsig); |  | ||||||
|                     warnings.append(&mut map_res.warnings); |  | ||||||
|                     if let Some(c) = map_res.c { |  | ||||||
|                         video_only_streams.push(c); |  | ||||||
|                     }; |  | ||||||
|                 } |  | ||||||
|                 (false, true) => { |  | ||||||
|                     let mut map_res = map_audio_stream(f, deobf, &mut last_nsig); |  | ||||||
|                     warnings.append(&mut map_res.warnings); |  | ||||||
|                     if let Some(c) = map_res.c { |  | ||||||
|                         audio_streams.push(c); |  | ||||||
|                     }; |  | ||||||
|                 } |  | ||||||
|                 (false, false) => warnings.push(format!("invalid format: {}", f.itag)), |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         video_streams.sort(); |  | ||||||
|         video_only_streams.sort(); |  | ||||||
|         audio_streams.sort(); |  | ||||||
| 
 |  | ||||||
|         let mut subtitles = vec![]; |  | ||||||
|         if let Some(captions) = self.captions { |  | ||||||
|             for c in captions.player_captions_tracklist_renderer.caption_tracks { |  | ||||||
|                 let lang_auto = c.name.strip_suffix(" (auto-generated)"); |  | ||||||
| 
 |  | ||||||
|                 subtitles.push(Subtitle { |  | ||||||
|                     url: c.base_url, |  | ||||||
|                     lang: c.language_code, |  | ||||||
|                     lang_name: lang_auto.unwrap_or(&c.name).to_owned(), |  | ||||||
|                     auto_generated: lang_auto.is_some(), |  | ||||||
|                 }) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         Ok(MapResult { |  | ||||||
|             c: VideoPlayer { |  | ||||||
|                 info: video_info, |  | ||||||
|                 video_streams, |  | ||||||
|                 video_only_streams, |  | ||||||
|                 audio_streams, |  | ||||||
|                 subtitles, |  | ||||||
|                 expires_in_seconds: streaming_data.expires_in_seconds, |  | ||||||
|             }, |  | ||||||
|             warnings, |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn cipher_to_url_params( |  | ||||||
|     signature_cipher: &str, |  | ||||||
|     deobf: &Deobfuscator, |  | ||||||
| ) -> Result<(String, BTreeMap<String, String>)> { |  | ||||||
|     let params: HashMap<Cow<str>, Cow<str>> = |  | ||||||
|         url::form_urlencoded::parse(signature_cipher.as_bytes()).collect(); |  | ||||||
| 
 |  | ||||||
|     // Parameters:
 |  | ||||||
|     // `s`: Obfuscated signature
 |  | ||||||
|     // `sp`: Signature parameter
 |  | ||||||
|     // `url`: URL that is missing the signature parameter
 |  | ||||||
| 
 |  | ||||||
|     let sig = some_or_bail!(params.get("s"), Err(anyhow!("no s param"))); |  | ||||||
|     let sp = some_or_bail!(params.get("sp"), Err(anyhow!("no sp param"))); |  | ||||||
|     let raw_url = some_or_bail!(params.get("url"), Err(anyhow!("no url param"))); |  | ||||||
|     let (url_base, mut url_params) = util::url_to_params(raw_url)?; |  | ||||||
| 
 |  | ||||||
|     // println!("sig: {}", sig);
 |  | ||||||
|     let deobf_sig = deobf.deobfuscate_sig(sig)?; |  | ||||||
|     url_params.insert(sp.to_string(), deobf_sig); |  | ||||||
| 
 |  | ||||||
|     Ok((url_base, url_params)) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn deobf_nsig( |  | ||||||
|     url_params: &mut BTreeMap<String, String>, |  | ||||||
|     deobf: &Deobfuscator, |  | ||||||
|     last_nsig: &mut [String; 2], |  | ||||||
| ) -> Result<()> { |  | ||||||
|     let nsig: String; |  | ||||||
|     match url_params.get("n") { |  | ||||||
|         Some(n) => { |  | ||||||
|             nsig = if n.to_owned() == last_nsig[0] { |  | ||||||
|                 last_nsig[1].to_owned() |  | ||||||
|             } else { |  | ||||||
|                 let nsig = deobf.deobfuscate_nsig(n)?; |  | ||||||
|                 last_nsig[0] = n.to_string(); |  | ||||||
|                 last_nsig[1] = nsig.to_owned(); |  | ||||||
|                 nsig |  | ||||||
|             }; |  | ||||||
| 
 |  | ||||||
|             url_params.insert("n".to_owned(), nsig); |  | ||||||
|         } |  | ||||||
|         None => {} |  | ||||||
|     }; |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn map_url( |  | ||||||
|     url: &Option<String>, |  | ||||||
|     signature_cipher: &Option<String>, |  | ||||||
|     deobf: &Deobfuscator, |  | ||||||
|     last_nsig: &mut [String; 2], |  | ||||||
| ) -> MapResult<Option<(String, bool)>> { |  | ||||||
|     let (url_base, mut url_params) = match url { |  | ||||||
|         Some(url) => ok_or_bail!( |  | ||||||
|             util::url_to_params(url), |  | ||||||
|             MapResult { |  | ||||||
|                 c: None, |  | ||||||
|                 warnings: vec![format!("Could not parse url `{}`", url)] |  | ||||||
|             } |  | ||||||
|         ), |  | ||||||
|         None => match signature_cipher { |  | ||||||
|             Some(signature_cipher) => match cipher_to_url_params(signature_cipher, deobf) { |  | ||||||
|                 Ok(res) => res, |  | ||||||
|                 Err(e) => { |  | ||||||
|                     return MapResult { |  | ||||||
|                         c: None, |  | ||||||
|                         warnings: vec![format!( |  | ||||||
|                             "Could not deobfuscate signatureCipher `{}`: {}", |  | ||||||
|                             signature_cipher, e |  | ||||||
|                         )], |  | ||||||
|                     }; |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             None => { |  | ||||||
|                 return MapResult { |  | ||||||
|                     c: None, |  | ||||||
|                     warnings: vec!["stream contained neither url nor cipher".to_owned()], |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     let mut warnings = vec![]; |  | ||||||
|     let mut throttled = false; |  | ||||||
|     deobf_nsig(&mut url_params, deobf, last_nsig).unwrap_or_else(|e| { |  | ||||||
|         warnings.push(format!( |  | ||||||
|             "Could not deobfuscate nsig (params: {:?}): {}", |  | ||||||
|             url_params, e |  | ||||||
|         )); |  | ||||||
|         throttled = true; |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     MapResult { |  | ||||||
|         c: Some(( |  | ||||||
|             ok_or_bail!( |  | ||||||
|                 Url::parse_with_params(url_base.as_str(), url_params.iter()), |  | ||||||
|                 MapResult { |  | ||||||
|                     c: None, |  | ||||||
|                     warnings: vec![format!( |  | ||||||
|                         "url could not be joined. url: `{}` params: {:?}", |  | ||||||
|                         url_base, url_params |  | ||||||
|                     )], |  | ||||||
|                 } |  | ||||||
|             ) |  | ||||||
|             .to_string(), |  | ||||||
|             throttled, |  | ||||||
|         )), |  | ||||||
|         warnings, |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn map_video_stream( |  | ||||||
|     f: player::Format, |  | ||||||
|     deobf: &Deobfuscator, |  | ||||||
|     last_nsig: &mut [String; 2], |  | ||||||
| ) -> MapResult<Option<VideoStream>> { |  | ||||||
|     let (mtype, codecs) = some_or_bail!( |  | ||||||
|         parse_mime(&f.mime_type), |  | ||||||
|         MapResult { |  | ||||||
|             c: None, |  | ||||||
|             warnings: vec![format!( |  | ||||||
|                 "Invalid mime type `{}` in video format {:?}", |  | ||||||
|                 &f.mime_type, &f |  | ||||||
|             )] |  | ||||||
|         } |  | ||||||
|     ); |  | ||||||
|     let map_res = map_url(&f.url, &f.signature_cipher, deobf, last_nsig); |  | ||||||
| 
 |  | ||||||
|     match map_res.c { |  | ||||||
|         Some((url, throttled)) => MapResult { |  | ||||||
|             c: Some(VideoStream { |  | ||||||
|                 url, |  | ||||||
|                 itag: f.itag, |  | ||||||
|                 bitrate: f.bitrate, |  | ||||||
|                 average_bitrate: f.average_bitrate, |  | ||||||
|                 size: f.content_length, |  | ||||||
|                 index_range: f.index_range, |  | ||||||
|                 init_range: f.init_range, |  | ||||||
|                 width: some_or_bail!( |  | ||||||
|                     f.width, |  | ||||||
|                     MapResult { |  | ||||||
|                         c: None, |  | ||||||
|                         warnings: map_res.warnings |  | ||||||
|                     } |  | ||||||
|                 ), |  | ||||||
|                 height: some_or_bail!( |  | ||||||
|                     f.height, |  | ||||||
|                     MapResult { |  | ||||||
|                         c: None, |  | ||||||
|                         warnings: map_res.warnings |  | ||||||
|                     } |  | ||||||
|                 ), |  | ||||||
|                 fps: some_or_bail!( |  | ||||||
|                     f.fps, |  | ||||||
|                     MapResult { |  | ||||||
|                         c: None, |  | ||||||
|                         warnings: map_res.warnings |  | ||||||
|                     } |  | ||||||
|                 ), |  | ||||||
|                 quality: some_or_bail!( |  | ||||||
|                     f.quality_label, |  | ||||||
|                     MapResult { |  | ||||||
|                         c: None, |  | ||||||
|                         warnings: map_res.warnings |  | ||||||
|                     } |  | ||||||
|                 ), |  | ||||||
|                 hdr: f.color_info.unwrap_or_default().primaries |  | ||||||
|                     == player::Primaries::ColorPrimariesBt2020, |  | ||||||
|                 mime: f.mime_type.to_owned(), |  | ||||||
|                 format: some_or_bail!( |  | ||||||
|                     get_video_format(mtype), |  | ||||||
|                     MapResult { |  | ||||||
|                         c: None, |  | ||||||
|                         warnings: vec![format!("no valid format in video format")] |  | ||||||
|                     } |  | ||||||
|                 ), |  | ||||||
|                 codec: get_video_codec(codecs), |  | ||||||
|                 throttled, |  | ||||||
|             }), |  | ||||||
|             warnings: map_res.warnings, |  | ||||||
|         }, |  | ||||||
|         None => MapResult { |  | ||||||
|             c: None, |  | ||||||
|             warnings: map_res.warnings, |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn map_audio_stream( |  | ||||||
|     f: player::Format, |  | ||||||
|     deobf: &Deobfuscator, |  | ||||||
|     last_nsig: &mut [String; 2], |  | ||||||
| ) -> MapResult<Option<AudioStream>> { |  | ||||||
|     static LANG_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r#"^([a-z]{2})\."#).unwrap()); |  | ||||||
| 
 |  | ||||||
|     let (mtype, codecs) = some_or_bail!( |  | ||||||
|         parse_mime(&f.mime_type), |  | ||||||
|         MapResult { |  | ||||||
|             c: None, |  | ||||||
|             warnings: vec![format!( |  | ||||||
|                 "Invalid mime type `{}` in video format {:?}", |  | ||||||
|                 &f.mime_type, &f |  | ||||||
|             )] |  | ||||||
|         } |  | ||||||
|     ); |  | ||||||
|     let map_res = map_url(&f.url, &f.signature_cipher, deobf, last_nsig); |  | ||||||
| 
 |  | ||||||
|     match map_res.c { |  | ||||||
|         Some((url, throttled)) => MapResult { |  | ||||||
|             c: Some(AudioStream { |  | ||||||
|                 url, |  | ||||||
|                 itag: f.itag, |  | ||||||
|                 bitrate: f.bitrate, |  | ||||||
|                 average_bitrate: f.average_bitrate, |  | ||||||
|                 size: f.content_length, |  | ||||||
|                 index_range: f.index_range, |  | ||||||
|                 init_range: f.init_range, |  | ||||||
|                 mime: f.mime_type.to_owned(), |  | ||||||
|                 format: some_or_bail!( |  | ||||||
|                     get_audio_format(mtype), |  | ||||||
|                     MapResult { |  | ||||||
|                         c: None, |  | ||||||
|                         warnings: vec![format!("invalid format in audio format {}", f.itag)] |  | ||||||
|                     } |  | ||||||
|                 ), |  | ||||||
|                 codec: get_audio_codec(codecs), |  | ||||||
|                 throttled, |  | ||||||
|                 track: match f.audio_track { |  | ||||||
|                     Some(t) => { |  | ||||||
|                         let lang = LANG_PATTERN |  | ||||||
|                             .captures(&t.id) |  | ||||||
|                             .ok() |  | ||||||
|                             .flatten() |  | ||||||
|                             .map(|m| m.get(1).unwrap().as_str().to_owned()); |  | ||||||
| 
 |  | ||||||
|                         Some(AudioTrack { |  | ||||||
|                             id: t.id, |  | ||||||
|                             lang, |  | ||||||
|                             lang_name: t.display_name, |  | ||||||
|                             is_default: t.audio_is_default, |  | ||||||
|                         }) |  | ||||||
|                     } |  | ||||||
|                     None => None, |  | ||||||
|                 }, |  | ||||||
|             }), |  | ||||||
|             warnings: map_res.warnings, |  | ||||||
|         }, |  | ||||||
|         None => MapResult { |  | ||||||
|             c: None, |  | ||||||
|             warnings: map_res.warnings, |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn parse_mime(mime: &str) -> Option<(&str, Vec<&str>)> { |  | ||||||
|     static PATTERN: Lazy<Regex> = |  | ||||||
|         Lazy::new(|| Regex::new(r#"(\w+/\w+);\scodecs="([a-zA-Z-0-9.,\s]*)""#).unwrap()); |  | ||||||
| 
 |  | ||||||
|     let captures = some_or_bail!(PATTERN.captures(&mime).ok().flatten(), None); |  | ||||||
|     Some(( |  | ||||||
|         captures.get(1).unwrap().as_str(), |  | ||||||
|         captures |  | ||||||
|             .get(2) |  | ||||||
|             .unwrap() |  | ||||||
|             .as_str() |  | ||||||
|             .split(", ") |  | ||||||
|             .collect::<Vec<&str>>(), |  | ||||||
|     )) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn get_video_format(mtype: &str) -> Option<VideoFormat> { |  | ||||||
|     match mtype { |  | ||||||
|         "video/3gpp" => Some(VideoFormat::ThreeGp), |  | ||||||
|         "video/mp4" => Some(VideoFormat::Mp4), |  | ||||||
|         "video/webm" => Some(VideoFormat::Webm), |  | ||||||
|         _ => None, |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn get_video_codec(codecs: Vec<&str>) -> VideoCodec { |  | ||||||
|     for codec in codecs { |  | ||||||
|         if codec.starts_with("avc1") { |  | ||||||
|             return VideoCodec::Avc1; |  | ||||||
|         } else if codec.starts_with("vp9") || codec.starts_with("vp09") { |  | ||||||
|             return VideoCodec::Vp9; |  | ||||||
|         } else if codec.starts_with("av01") { |  | ||||||
|             return VideoCodec::Av01; |  | ||||||
|         } else if codec.starts_with("mp4v") { |  | ||||||
|             return VideoCodec::Mp4v; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     VideoCodec::Unknown |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn get_audio_format(mtype: &str) -> Option<AudioFormat> { |  | ||||||
|     match mtype { |  | ||||||
|         "audio/mp4" => Some(AudioFormat::M4a), |  | ||||||
|         "audio/webm" => Some(AudioFormat::Webm), |  | ||||||
|         _ => None, |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn get_audio_codec(codecs: Vec<&str>) -> AudioCodec { |  | ||||||
|     for codec in codecs { |  | ||||||
|         if codec.starts_with("mp4a") { |  | ||||||
|             return AudioCodec::Mp4a; |  | ||||||
|         } else if codec.starts_with("opus") { |  | ||||||
|             return AudioCodec::Opus; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     AudioCodec::Unknown |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
|     use std::{fs::File, io::BufReader, path::Path}; |  | ||||||
| 
 |  | ||||||
|     use crate::{cache::DeobfData, client2::CLIENT_TYPES, report::TestFileReporter}; |  | ||||||
| 
 |  | ||||||
|     use super::*; |  | ||||||
|     use rstest::rstest; |  | ||||||
| 
 |  | ||||||
|     static DEOBFUSCATOR: Lazy<Deobfuscator> = Lazy::new(|| { |  | ||||||
|         Deobfuscator::from(DeobfData { |  | ||||||
|         js_url: "https://www.youtube.com/s/player/c8b8a173/player_ias.vflset/en_US/base.js".to_owned(), |  | ||||||
|         sig_fn: "var oB={B4:function(a){a.reverse()},xm:function(a,b){a.splice(0,b)},dC:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}};var Vva=function(a){a=a.split(\"\");oB.dC(a,42);oB.xm(a,3);oB.dC(a,48);oB.B4(a,68);return a.join(\"\")};function deobfuscate(a){return Vva(a);}".to_owned(), |  | ||||||
|         nsig_fn: "Ska=function(a){var b=a.split(\"\"),c=[-1505243983,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},\n-1692381986,function(d,e){e=(e%d.length+d.length)%d.length;var f=d[0];d[0]=d[e];d[e]=f},\n-262444939,\"unshift\",function(d){for(var e=d.length;e;)d.push(d.splice(--e,1)[0])},\n1201502951,-546377604,-504264123,-1978377336,1042456724,function(d,e){for(e=(e%d.length+d.length)%d.length;e--;)d.unshift(d.pop())},\n711986897,406699922,-1842537993,-1678108293,1803491779,1671716087,12778705,-718839990,null,null,-1617525823,342523552,-1338406651,-399705108,-696713950,b,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(0,1,d.splice(e,1,d[0])[0])},\nfunction(d,e){e=(e%d.length+d.length)%d.length;d.splice(e,1)},\n-980602034,356396192,null,-1617525823,function(d,e,f){var h=f.length;d.forEach(function(l,m,n){this.push(n[m]=f[(f.indexOf(l)-f.indexOf(this[m])+m+h--)%f.length])},e.split(\"\"))},\n-1029864222,-641353250,-1681901809,-1391247867,1707415199,-1957855835,b,function(){for(var d=64,e=[];++d-e.length-32;)switch(d){case 58:d=96;continue;case 91:d=44;break;case 65:d=47;continue;case 46:d=153;case 123:d-=58;default:e.push(String.fromCharCode(d))}return e},\n-1936558978,-1505243983,function(d){d.reverse()},\n1296889058,-1813915420,-943019300,function(d,e,f){var h=f.length;d.forEach(function(l,m,n){this.push(n[m]=f[(f.indexOf(l)-f.indexOf(this[m])+m+h--)%f.length])},e.split(\"\"))},\n\"join\",b,-2061642263];c[21]=c;c[22]=c;c[33]=c;try{c[3](c[33],c[9]),c[29](c[22],c[25]),c[29](c[22],c[19]),c[29](c[33],c[17]),c[29](c[21],c[2]),c[29](c[42],c[10]),c[1](c[52],c[40]),c[12](c[28],c[8]),c[29](c[21],c[45]),c[1](c[21],c[48]),c[44](c[26]),c[39](c[5],c[2]),c[31](c[53],c[16]),c[30](c[29],c[8]),c[51](c[29],c[6],c[44]()),c[4](c[43],c[1]),c[2](c[23],c[42]),c[2](c[0],c[46]),c[38](c[14],c[52]),c[32](c[5]),c[26](c[29],c[46]),c[26](c[5],c[13]),c[28](c[1],c[37]),c[26](c[31],c[13]),c[26](c[1],c[34]),\nc[46](c[1],c[32],c[40]()),c[26](c[50],c[44]),c[17](c[50],c[51]),c[0](c[3],c[24]),c[32](c[13]),c[43](c[3],c[51]),c[0](c[34],c[17]),c[16](c[45],c[53]),c[29](c[44],c[13]),c[42](c[1],c[50]),c[47](c[22],c[53]),c[37](c[22]),c[13](c[52],c[21]),c[6](c[43],c[34]),c[6](c[31],c[46])}catch(d){return\"enhanced_except_gZYB_un-_w8_\"+a}return b.join(\"\")};function deobfuscate(a){return Ska(a);}".to_owned(), |  | ||||||
|         sts: "19201".to_owned(), |  | ||||||
|     }) |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     #[test_log::test(tokio::test)] |  | ||||||
|     async fn download_response_testfiles() { |  | ||||||
|         let tf_dir = Path::new("testfiles/player"); |  | ||||||
|         let video_id = "pPvd8UxmSbQ"; |  | ||||||
| 
 |  | ||||||
|         for client_type in CLIENT_TYPES { |  | ||||||
|             let mut json_path = tf_dir.to_path_buf(); |  | ||||||
|             json_path.push(format!("{:?}_video.json", client_type).to_lowercase()); |  | ||||||
|             if json_path.exists() { |  | ||||||
|                 continue; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             let reporter = TestFileReporter::new(json_path); |  | ||||||
|             let rp = RustyPipe::new(None, Some(Box::new(reporter)), None).report(true); |  | ||||||
|             rp.get_player(video_id, client_type).await.unwrap(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[test_log::test(tokio::test)] |  | ||||||
|     async fn download_model_testfiles() { |  | ||||||
|         let tf_dir = Path::new("testfiles/player_model"); |  | ||||||
|         let rp = RustyPipe::default(); |  | ||||||
| 
 |  | ||||||
|         for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] { |  | ||||||
|             let mut json_path = tf_dir.to_path_buf(); |  | ||||||
|             json_path.push(format!("{}.json", name).to_lowercase()); |  | ||||||
|             if json_path.exists() { |  | ||||||
|                 continue; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             let player_data = rp.get_player(id, ClientType::Desktop).await.unwrap(); |  | ||||||
|             let file = File::create(json_path).unwrap(); |  | ||||||
|             serde_json::to_writer_pretty(file, &player_data).unwrap(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |  | ||||||
|     #[case::desktop("desktop")] |  | ||||||
|     #[case::desktop_music("desktopmusic")] |  | ||||||
|     #[case::tv_html5_embed("tvhtml5embed")] |  | ||||||
|     #[case::android("android")] |  | ||||||
|     #[case::ios("ios")] |  | ||||||
|     fn t_map_player_data(#[case] name: &str) { |  | ||||||
|         let filename = format!("testfiles/player/{}_video.json", name); |  | ||||||
|         let json_path = Path::new(&filename); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
| 
 |  | ||||||
|         let resp: response::Player = serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let map_res = resp |  | ||||||
|             .map_response("pPvd8UxmSbQ", Language::En, Some(&DEOBFUSCATOR)) |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert!( |  | ||||||
|             map_res.warnings.is_empty(), |  | ||||||
|             "deserialization/mapping warnings: {:?}", |  | ||||||
|             map_res.warnings |  | ||||||
|         ); |  | ||||||
|         let is_desktop = name == "desktop" || name == "desktopmusic"; |  | ||||||
|         insta::assert_yaml_snapshot!(format!("map_player_data_{}", name), map_res.c, { |  | ||||||
|             ".info.publish_date" => insta::dynamic_redaction(move |value, _path| { |  | ||||||
|                 if is_desktop { |  | ||||||
|                     assert!(value.as_str().unwrap().starts_with("2019-05-30T00:00:00")); |  | ||||||
|                     "2019-05-30T00:00:00" |  | ||||||
|                 } else { |  | ||||||
|                     assert_eq!(value, insta::internals::Content::None); |  | ||||||
|                     "~" |  | ||||||
|                 } |  | ||||||
|             }), |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /// Assert equality within 10% margin
 |  | ||||||
|     fn assert_approx(left: f64, right: f64) { |  | ||||||
|         if left != right { |  | ||||||
|             let f = left / right; |  | ||||||
|             assert!( |  | ||||||
|                 0.9 < f && f < 1.1, |  | ||||||
|                 "{} not within 10% margin of {}", |  | ||||||
|                 left, |  | ||||||
|                 right |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |  | ||||||
|     #[case::desktop(ClientType::Desktop)] |  | ||||||
|     #[case::tv_html5_embed(ClientType::TvHtml5Embed)] |  | ||||||
|     #[case::android(ClientType::Android)] |  | ||||||
|     #[case::ios(ClientType::Ios)] |  | ||||||
|     #[test_log::test(tokio::test)] |  | ||||||
|     async fn t_get_player(#[case] client_type: ClientType) { |  | ||||||
|         let rp = RustyPipe::default(); |  | ||||||
|         let player_data = rp.get_player("n4tK7LYFxI0", client_type).await.unwrap(); |  | ||||||
| 
 |  | ||||||
|         // dbg!(&player_data);
 |  | ||||||
| 
 |  | ||||||
|         assert_eq!(player_data.info.id, "n4tK7LYFxI0"); |  | ||||||
|         assert_eq!(player_data.info.title, "Spektrem - Shine [NCS Release]"); |  | ||||||
|         if client_type == ClientType::DesktopMusic { |  | ||||||
|             assert!(player_data.info.description.is_none()); |  | ||||||
|         } else { |  | ||||||
|             assert!(player_data.info.description.unwrap().starts_with( |  | ||||||
|                 "NCS (NoCopyrightSounds): Empowering Creators through Copyright / Royalty Free Music" |  | ||||||
|             )); |  | ||||||
|         } |  | ||||||
|         assert_eq!(player_data.info.length, 259); |  | ||||||
|         assert!(!player_data.info.thumbnails.is_empty()); |  | ||||||
|         assert_eq!(player_data.info.channel.id, "UC_aEa8K-EOJ3D6gOs7HcyNg"); |  | ||||||
|         assert_eq!(player_data.info.channel.name, "NoCopyrightSounds"); |  | ||||||
|         assert!(player_data.info.view_count > 146818808); |  | ||||||
|         assert_eq!(player_data.info.keywords[0], "spektrem"); |  | ||||||
|         assert_eq!(player_data.info.is_live_content, false); |  | ||||||
| 
 |  | ||||||
|         if client_type == ClientType::Desktop || client_type == ClientType::DesktopMusic { |  | ||||||
|             assert!(player_data |  | ||||||
|                 .info |  | ||||||
|                 .publish_date |  | ||||||
|                 .unwrap() |  | ||||||
|                 .to_string() |  | ||||||
|                 .starts_with("2013-05-05 00:00:00")); |  | ||||||
|             assert_eq!(player_data.info.category.unwrap(), "Music"); |  | ||||||
|             assert_eq!(player_data.info.is_family_safe.unwrap(), true); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if client_type == ClientType::Ios { |  | ||||||
|             let video = player_data |  | ||||||
|                 .video_only_streams |  | ||||||
|                 .iter() |  | ||||||
|                 .find(|s| s.itag == 247) |  | ||||||
|                 .unwrap(); |  | ||||||
|             let audio = player_data |  | ||||||
|                 .audio_streams |  | ||||||
|                 .iter() |  | ||||||
|                 .find(|s| s.itag == 140) |  | ||||||
|                 .unwrap(); |  | ||||||
| 
 |  | ||||||
|             // Bitrates may change between requests
 |  | ||||||
|             assert_approx(video.bitrate as f64, 1507068.0); |  | ||||||
|             assert_eq!(video.average_bitrate, 1345149); |  | ||||||
|             assert_eq!(video.size, 43553412); |  | ||||||
|             assert_eq!(video.width, 1280); |  | ||||||
|             assert_eq!(video.height, 720); |  | ||||||
|             assert_eq!(video.fps, 30); |  | ||||||
|             assert_eq!(video.quality, "720p"); |  | ||||||
|             assert_eq!(video.hdr, false); |  | ||||||
|             assert_eq!(video.mime, "video/webm; codecs=\"vp09.00.31.08\""); |  | ||||||
|             assert_eq!(video.format, VideoFormat::Webm); |  | ||||||
|             assert_eq!(video.codec, VideoCodec::Vp9); |  | ||||||
| 
 |  | ||||||
|             assert_approx(audio.bitrate as f64, 130685.0); |  | ||||||
|             assert_eq!(audio.average_bitrate, 129496); |  | ||||||
|             assert_eq!(audio.size, 4193863); |  | ||||||
|             assert_eq!(audio.mime, "audio/mp4; codecs=\"mp4a.40.2\""); |  | ||||||
|             assert_eq!(audio.format, AudioFormat::M4a); |  | ||||||
|             assert_eq!(audio.codec, AudioCodec::Mp4a); |  | ||||||
|         } else { |  | ||||||
|             let video = player_data |  | ||||||
|                 .video_only_streams |  | ||||||
|                 .iter() |  | ||||||
|                 .find(|s| s.itag == 398) |  | ||||||
|                 .unwrap(); |  | ||||||
|             let audio = player_data |  | ||||||
|                 .audio_streams |  | ||||||
|                 .iter() |  | ||||||
|                 .find(|s| s.itag == 251) |  | ||||||
|                 .unwrap(); |  | ||||||
| 
 |  | ||||||
|             assert_approx(video.bitrate as f64, 1340829.0); |  | ||||||
|             assert_approx(video.average_bitrate as f64, 1233444.0); |  | ||||||
|             assert_approx(video.size as f64, 39936630.0); |  | ||||||
|             assert_eq!(video.width, 1280); |  | ||||||
|             assert_eq!(video.height, 720); |  | ||||||
|             assert_eq!(video.fps, 30); |  | ||||||
|             assert_eq!(video.quality, "720p"); |  | ||||||
|             assert_eq!(video.hdr, false); |  | ||||||
|             assert_eq!(video.mime, "video/mp4; codecs=\"av01.0.05M.08\""); |  | ||||||
|             assert_eq!(video.format, VideoFormat::Mp4); |  | ||||||
|             assert_eq!(video.codec, VideoCodec::Av01); |  | ||||||
|             assert_eq!(video.throttled, false); |  | ||||||
| 
 |  | ||||||
|             assert_approx(audio.bitrate as f64, 142718.0); |  | ||||||
|             assert_approx(audio.average_bitrate as f64, 130708.0); |  | ||||||
|             assert_approx(audio.size as f64, 4232344.0); |  | ||||||
|             assert_eq!(audio.mime, "audio/webm; codecs=\"opus\""); |  | ||||||
|             assert_eq!(audio.format, AudioFormat::Webm); |  | ||||||
|             assert_eq!(audio.codec, AudioCodec::Opus); |  | ||||||
|             assert_eq!(audio.throttled, false); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         assert!(player_data.expires_in_seconds > 10000); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[test] |  | ||||||
|     fn t_cipher_to_url() { |  | ||||||
|         let signature_cipher = "s=w%3DAe%3DA6aDNQLkViKS7LOm9QtxZJHKwb53riq9qEFw-ecBWJCAiA%3DcEg0tn3dty9jEHszfzh4Ud__bg9CEHVx4ix-7dKsIPAhIQRw8JQ0qOA&sp=sig&url=https://rr5---sn-h0jelnez.googlevideo.com/videoplayback%3Fexpire%3D1659376413%26ei%3Dvb7nYvH5BMK8gAfBj7ToBQ%26ip%3D2003%253Ade%253Aaf06%253A6300%253Ac750%253A1b77%253Ac74a%253A80e3%26id%3Do-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2%26itag%3D251%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DhH%26mm%3D31%252C26%26mn%3Dsn-h0jelnez%252Csn-4g5ednsl%26ms%3Dau%252Conr%26mv%3Dm%26mvi%3D5%26pl%3D37%26initcwndbps%3D1588750%26spc%3DlT-Khi831z8dTejFIRCvCEwx_6romtM%26vprv%3D1%26mime%3Daudio%252Fwebm%26ns%3Db_Mq_qlTFcSGlG9RpwpM9xQH%26gir%3Dyes%26clen%3D3781277%26dur%3D229.301%26lmt%3D1655510291473933%26mt%3D1659354538%26fvip%3D5%26keepalive%3Dyes%26fexp%3D24001373%252C24007246%26c%3DWEB%26rbqsm%3Dfr%26txp%3D4532434%26n%3Dd2g6G2hVqWIXxedQ%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cspc%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%253D%253D"; |  | ||||||
|         let mut last_nsig: [String; 2] = ["".to_owned(), "".to_owned()]; |  | ||||||
|         let map_res = map_url( |  | ||||||
|             &None, |  | ||||||
|             &Some(signature_cipher.to_owned()), |  | ||||||
|             &DEOBFUSCATOR, |  | ||||||
|             &mut last_nsig, |  | ||||||
|         ); |  | ||||||
|         let (url, throttled) = map_res.c.unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert_eq!(url, "https://rr5---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=3781277&dur=229.301&ei=vb7nYvH5BMK8gAfBj7ToBQ&expire=1659376413&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2&initcwndbps=1588750&ip=2003%3Ade%3Aaf06%3A6300%3Ac750%3A1b77%3Ac74a%3A80e3&itag=251&keepalive=yes&lmt=1655510291473933&lsig=AG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=hH&mime=audio%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5ednsl&ms=au%2Conr&mt=1659354538&mv=m&mvi=5&n=XzXGSfGusw6OCQ&ns=b_Mq_qlTFcSGlG9RpwpM9xQH&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAPIsKd7-xi4xVHEC9gb__dU4hzfzsHEj9ytd3nt0gEceAiACJWBcw-wFEq9qir35bwKHJZxtQ9mOL7SKiVkLQNDa6A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&spc=lT-Khi831z8dTejFIRCvCEwx_6romtM&txp=4532434&vprv=1"); |  | ||||||
|         assert_eq!(throttled, false); |  | ||||||
|         assert!( |  | ||||||
|             map_res.warnings.is_empty(), |  | ||||||
|             "deserialization/mapping warnings: {:?}", |  | ||||||
|             map_res.warnings |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,427 +0,0 @@ | ||||||
| use anyhow::{anyhow, bail, Result}; |  | ||||||
| use reqwest::Method; |  | ||||||
| use serde::Serialize; |  | ||||||
| 
 |  | ||||||
| use crate::{ |  | ||||||
|     deobfuscate::Deobfuscator, |  | ||||||
|     model::{Channel, Language, Playlist, Thumbnail, Video}, |  | ||||||
|     serializer::text::{PageType, TextLink}, |  | ||||||
|     timeago, util, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| use super::{response, ClientType, ContextYT, MapResponse, MapResult, RustyPipe}; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct QPlaylist { |  | ||||||
|     context: ContextYT, |  | ||||||
|     browse_id: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Serialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| struct QPlaylistCont { |  | ||||||
|     context: ContextYT, |  | ||||||
|     continuation: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl RustyPipe { |  | ||||||
|     pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> { |  | ||||||
|         let context = self.get_context(ClientType::Desktop, true).await; |  | ||||||
|         let request_body = QPlaylist { |  | ||||||
|             context, |  | ||||||
|             browse_id: "VL".to_owned() + playlist_id, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         self.execute_request::<response::Playlist, _, _>( |  | ||||||
|             ClientType::Desktop, |  | ||||||
|             "get_playlist", |  | ||||||
|             Method::POST, |  | ||||||
|             "browse", |  | ||||||
|             playlist_id, |  | ||||||
|             &request_body, |  | ||||||
|             None, |  | ||||||
|         ) |  | ||||||
|         .await |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn get_playlist_cont(&self, playlist: &mut Playlist) -> Result<()> { |  | ||||||
|         match &playlist.ctoken { |  | ||||||
|             Some(ctoken) => { |  | ||||||
|                 let context = self.get_context(ClientType::Desktop, true).await; |  | ||||||
|                 let request_body = QPlaylistCont { |  | ||||||
|                     context, |  | ||||||
|                     continuation: ctoken.to_owned(), |  | ||||||
|                 }; |  | ||||||
| 
 |  | ||||||
|                 let (mut videos, ctoken) = self |  | ||||||
|                     .execute_request::<response::PlaylistCont, _, _>( |  | ||||||
|                         ClientType::Desktop, |  | ||||||
|                         "get_playlist_cont", |  | ||||||
|                         Method::POST, |  | ||||||
|                         "browse", |  | ||||||
|                         &playlist.id, |  | ||||||
|                         &request_body, |  | ||||||
|                         None, |  | ||||||
|                     ) |  | ||||||
|                     .await?; |  | ||||||
| 
 |  | ||||||
|                 playlist.videos.append(&mut videos); |  | ||||||
|                 playlist.ctoken = ctoken; |  | ||||||
| 
 |  | ||||||
|                 if playlist.ctoken.is_none() { |  | ||||||
|                     playlist.n_videos = playlist.videos.len() as u32; |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 Ok(()) |  | ||||||
|             } |  | ||||||
|             None => Err(anyhow!("no ctoken")), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl MapResponse<Playlist> for response::Playlist { |  | ||||||
|     fn map_response( |  | ||||||
|         self, |  | ||||||
|         id: &str, |  | ||||||
|         lang: Language, |  | ||||||
|         _deobf: Option<&Deobfuscator>, |  | ||||||
|     ) -> Result<MapResult<Playlist>> { |  | ||||||
|         let video_items = &some_or_bail!( |  | ||||||
|             some_or_bail!( |  | ||||||
|                 some_or_bail!( |  | ||||||
|                     self.contents |  | ||||||
|                         .two_column_browse_results_renderer |  | ||||||
|                         .contents |  | ||||||
|                         .get(0), |  | ||||||
|                     Err(anyhow!("twoColumnBrowseResultsRenderer empty")) |  | ||||||
|                 ) |  | ||||||
|                 .tab_renderer |  | ||||||
|                 .content |  | ||||||
|                 .section_list_renderer |  | ||||||
|                 .contents |  | ||||||
|                 .get(0), |  | ||||||
|                 Err(anyhow!("sectionListRenderer empty")) |  | ||||||
|             ) |  | ||||||
|             .item_section_renderer |  | ||||||
|             .contents |  | ||||||
|             .get(0), |  | ||||||
|             Err(anyhow!("itemSectionRenderer empty")) |  | ||||||
|         ) |  | ||||||
|         .playlist_video_list_renderer |  | ||||||
|         .contents; |  | ||||||
| 
 |  | ||||||
|         let (videos, ctoken) = map_playlist_items(&video_items.c); |  | ||||||
| 
 |  | ||||||
|         let (thumbnails, last_update_txt) = match &self.sidebar { |  | ||||||
|             Some(sidebar) => { |  | ||||||
|                 let primary = some_or_bail!( |  | ||||||
|                     sidebar.playlist_sidebar_renderer.items.get(0), |  | ||||||
|                     Err(anyhow!("no primary sidebar")) |  | ||||||
|                 ); |  | ||||||
| 
 |  | ||||||
|                 ( |  | ||||||
|                     &primary |  | ||||||
|                         .playlist_sidebar_primary_info_renderer |  | ||||||
|                         .thumbnail_renderer |  | ||||||
|                         .playlist_video_thumbnail_renderer |  | ||||||
|                         .thumbnail |  | ||||||
|                         .thumbnails, |  | ||||||
|                     primary |  | ||||||
|                         .playlist_sidebar_primary_info_renderer |  | ||||||
|                         .stats |  | ||||||
|                         .get(2) |  | ||||||
|                         .map(|t| t.to_owned()), |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|             None => { |  | ||||||
|                 let header_banner = some_or_bail!( |  | ||||||
|                     &self.header.playlist_header_renderer.playlist_header_banner, |  | ||||||
|                     Err(anyhow!("no thumbnail found")) |  | ||||||
|                 ); |  | ||||||
| 
 |  | ||||||
|                 let last_update_txt = self |  | ||||||
|                     .header |  | ||||||
|                     .playlist_header_renderer |  | ||||||
|                     .byline |  | ||||||
|                     .get(1) |  | ||||||
|                     .map(|b| b.playlist_byline_renderer.text.to_owned()); |  | ||||||
| 
 |  | ||||||
|                 ( |  | ||||||
|                     &header_banner |  | ||||||
|                         .hero_playlist_thumbnail_renderer |  | ||||||
|                         .thumbnail |  | ||||||
|                         .thumbnails, |  | ||||||
|                     last_update_txt, |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let thumbnails = thumbnails |  | ||||||
|             .iter() |  | ||||||
|             .map(|t| Thumbnail { |  | ||||||
|                 url: t.url.to_owned(), |  | ||||||
|                 width: t.width, |  | ||||||
|                 height: t.height, |  | ||||||
|             }) |  | ||||||
|             .collect::<Vec<_>>(); |  | ||||||
| 
 |  | ||||||
|         let n_videos = match ctoken { |  | ||||||
|             Some(_) => { |  | ||||||
|                 ok_or_bail!( |  | ||||||
|                     util::parse_numeric(&self.header.playlist_header_renderer.num_videos_text), |  | ||||||
|                     Err(anyhow!("no video count")) |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|             None => videos.len() as u32, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let playlist_id = self.header.playlist_header_renderer.playlist_id; |  | ||||||
|         if playlist_id != id { |  | ||||||
|             bail!("got wrong playlist id {}, expected {}", playlist_id, id); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let name = self.header.playlist_header_renderer.title; |  | ||||||
|         let description = self.header.playlist_header_renderer.description_text; |  | ||||||
| 
 |  | ||||||
|         let channel = match self.header.playlist_header_renderer.owner_text { |  | ||||||
|             Some(owner_text) => match owner_text { |  | ||||||
|                 TextLink::Browse { |  | ||||||
|                     text, |  | ||||||
|                     page_type, |  | ||||||
|                     browse_id, |  | ||||||
|                 } => match page_type { |  | ||||||
|                     PageType::Channel => Some(Channel { |  | ||||||
|                         id: browse_id, |  | ||||||
|                         name: text, |  | ||||||
|                     }), |  | ||||||
|                     _ => None, |  | ||||||
|                 }, |  | ||||||
|                 _ => None, |  | ||||||
|             }, |  | ||||||
|             None => None, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         let mut warnings = video_items.warnings.to_owned(); |  | ||||||
|         let last_update = match &last_update_txt { |  | ||||||
|             Some(textual_date) => { |  | ||||||
|                 let parsed = timeago::parse_textual_date_to_dt(lang, textual_date); |  | ||||||
|                 if parsed.is_none() { |  | ||||||
|                     warnings.push(format!("could not parse textual date `{}`", textual_date)); |  | ||||||
|                 } |  | ||||||
|                 parsed |  | ||||||
|             } |  | ||||||
|             None => None, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         Ok(MapResult { |  | ||||||
|             c: Playlist { |  | ||||||
|                 id: playlist_id, |  | ||||||
|                 name, |  | ||||||
|                 videos, |  | ||||||
|                 n_videos, |  | ||||||
|                 ctoken, |  | ||||||
|                 thumbnails, |  | ||||||
|                 description, |  | ||||||
|                 channel, |  | ||||||
|                 last_update, |  | ||||||
|                 last_update_txt, |  | ||||||
|             }, |  | ||||||
|             warnings, |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl MapResponse<(Vec<Video>, Option<String>)> for response::PlaylistCont { |  | ||||||
|     fn map_response( |  | ||||||
|         self, |  | ||||||
|         id: &str, |  | ||||||
|         _lang: Language, |  | ||||||
|         _deobf: Option<&Deobfuscator>, |  | ||||||
|     ) -> Result<MapResult<(Vec<Video>, Option<String>)>> { |  | ||||||
|         let action = some_or_bail!( |  | ||||||
|             self.on_response_received_actions |  | ||||||
|                 .iter() |  | ||||||
|                 .find(|a| a.append_continuation_items_action.target_id == id), |  | ||||||
|             Err(anyhow!("no continuation action")) |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         Ok(MapResult { |  | ||||||
|             c: map_playlist_items(&action.append_continuation_items_action.continuation_items.c), |  | ||||||
|             warnings: action |  | ||||||
|                 .append_continuation_items_action |  | ||||||
|                 .continuation_items |  | ||||||
|                 .warnings |  | ||||||
|                 .to_owned(), |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn map_playlist_items( |  | ||||||
|     items: &Vec<response::VideoListItem<response::playlist::PlaylistVideo>>, |  | ||||||
| ) -> (Vec<Video>, Option<String>) { |  | ||||||
|     let mut ctoken: Option<String> = None; |  | ||||||
|     let videos = items |  | ||||||
|         .iter() |  | ||||||
|         .filter_map(|it| match it { |  | ||||||
|             response::VideoListItem::GridVideoRenderer { video } => match &video.channel { |  | ||||||
|                 TextLink::Browse { |  | ||||||
|                     text, |  | ||||||
|                     page_type, |  | ||||||
|                     browse_id, |  | ||||||
|                 } => match page_type { |  | ||||||
|                     PageType::Channel => Some(Video { |  | ||||||
|                         id: video.video_id.to_owned(), |  | ||||||
|                         title: video.title.to_owned(), |  | ||||||
|                         length: video.length_seconds, |  | ||||||
|                         thumbnails: video |  | ||||||
|                             .thumbnail |  | ||||||
|                             .thumbnails |  | ||||||
|                             .iter() |  | ||||||
|                             .map(|t| Thumbnail { |  | ||||||
|                                 url: t.url.to_owned(), |  | ||||||
|                                 width: t.width, |  | ||||||
|                                 height: t.height, |  | ||||||
|                             }) |  | ||||||
|                             .collect(), |  | ||||||
|                         channel: Channel { |  | ||||||
|                             id: browse_id.to_string(), |  | ||||||
|                             name: text.to_owned(), |  | ||||||
|                         }, |  | ||||||
|                     }), |  | ||||||
|                     _ => None, |  | ||||||
|                 }, |  | ||||||
|                 _ => None, |  | ||||||
|             }, |  | ||||||
|             response::VideoListItem::ContinuationItemRenderer { |  | ||||||
|                 continuation_endpoint, |  | ||||||
|             } => { |  | ||||||
|                 ctoken = Some(continuation_endpoint.continuation_command.token.to_owned()); |  | ||||||
|                 None |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .collect::<Vec<_>>(); |  | ||||||
|     (videos, ctoken) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| mod tests { |  | ||||||
|     use std::{fs::File, io::BufReader, path::Path}; |  | ||||||
| 
 |  | ||||||
|     use rstest::rstest; |  | ||||||
| 
 |  | ||||||
|     use crate::report::TestFileReporter; |  | ||||||
| 
 |  | ||||||
|     use super::*; |  | ||||||
| 
 |  | ||||||
|     #[rstest] |  | ||||||
|     #[case::long(
 |  | ||||||
|         "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ", |  | ||||||
|         "Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022", |  | ||||||
|         true, |  | ||||||
|         None, |  | ||||||
|         Some(Channel { |  | ||||||
|             id: "UCIekuFeMaV78xYfvpmoCnPg".to_owned(), |  | ||||||
|             name: "Best Music".to_owned(), |  | ||||||
|         }) |  | ||||||
|     )] |  | ||||||
|     #[case::short(
 |  | ||||||
|         "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk", |  | ||||||
|         "Easy Pop", |  | ||||||
|         false, |  | ||||||
|         None, |  | ||||||
|         None |  | ||||||
|     )] |  | ||||||
|     #[case::nomusic(
 |  | ||||||
|         "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe", |  | ||||||
|         "Minecraft SHINE", |  | ||||||
|         false, |  | ||||||
|         Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...".to_owned()), |  | ||||||
|         Some(Channel { |  | ||||||
|             id: "UCQM0bS4_04-Y4JuYrgmnpZQ".to_owned(), |  | ||||||
|             name: "Chaosflo44".to_owned(), |  | ||||||
|         }) |  | ||||||
|     )] |  | ||||||
|     #[test_log::test(tokio::test)] |  | ||||||
|     async fn t_get_playlist( |  | ||||||
|         #[case] id: &str, |  | ||||||
|         #[case] name: &str, |  | ||||||
|         #[case] is_long: bool, |  | ||||||
|         #[case] description: Option<String>, |  | ||||||
|         #[case] channel: Option<Channel>, |  | ||||||
|     ) { |  | ||||||
|         let rp = RustyPipe::default(); |  | ||||||
|         let playlist = rp.get_playlist(id).await.unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert_eq!(playlist.id, id); |  | ||||||
|         assert_eq!(playlist.name, name); |  | ||||||
|         assert!(!playlist.videos.is_empty()); |  | ||||||
|         assert_eq!(playlist.ctoken.is_some(), is_long); |  | ||||||
|         assert!(playlist.n_videos > 10); |  | ||||||
|         assert_eq!(playlist.n_videos > 100, is_long); |  | ||||||
|         assert_eq!(playlist.description, description); |  | ||||||
|         if channel.is_some() { |  | ||||||
|             assert_eq!(playlist.channel, channel); |  | ||||||
|         } |  | ||||||
|         assert!(!playlist.thumbnails.is_empty()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[test_log::test(tokio::test)] |  | ||||||
|     async fn download_testfiles() { |  | ||||||
|         let tf_dir = Path::new("testfiles/playlist"); |  | ||||||
| 
 |  | ||||||
|         for (name, id) in [ |  | ||||||
|             ("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"), |  | ||||||
|             ("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"), |  | ||||||
|             ("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"), |  | ||||||
|         ] { |  | ||||||
|             let mut json_path = tf_dir.to_path_buf(); |  | ||||||
|             json_path.push(format!("playlist_{}.json", name)); |  | ||||||
|             if json_path.exists() { |  | ||||||
|                 continue; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             let reporter = TestFileReporter::new(json_path); |  | ||||||
|             let rp = RustyPipe::new(None, Some(Box::new(reporter)), None).report(true); |  | ||||||
|             rp.get_playlist(id).await.unwrap(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[rstest] |  | ||||||
|     #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] |  | ||||||
|     #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] |  | ||||||
|     #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] |  | ||||||
|     fn t_map_playlist_data(#[case] name: &str, #[case] id: &str) { |  | ||||||
|         let filename = format!("testfiles/playlist/playlist_{}.json", name); |  | ||||||
|         let json_path = Path::new(&filename); |  | ||||||
|         let json_file = File::open(json_path).unwrap(); |  | ||||||
| 
 |  | ||||||
|         let playlist: response::Playlist = |  | ||||||
|             serde_json::from_reader(BufReader::new(json_file)).unwrap(); |  | ||||||
|         let map_res = playlist.map_response(id, Language::En, None).unwrap(); |  | ||||||
| 
 |  | ||||||
|         assert!( |  | ||||||
|             map_res.warnings.is_empty(), |  | ||||||
|             "deserialization/mapping warnings: {:?}", |  | ||||||
|             map_res.warnings |  | ||||||
|         ); |  | ||||||
|         insta::assert_yaml_snapshot!(format!("map_playlist_data_{}", name), map_res.c, { |  | ||||||
|             ".last_update" => "[date]" |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[test_log::test(tokio::test)] |  | ||||||
|     async fn t_playlist_cont() { |  | ||||||
|         let rp = RustyPipe::default(); |  | ||||||
|         let mut playlist = rp |  | ||||||
|             .get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi") |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         while playlist.ctoken.is_some() { |  | ||||||
|             rp.get_playlist_cont(&mut playlist).await.unwrap(); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         assert!(playlist.videos.len() > 100); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,74 +0,0 @@ | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_with::serde_as; |  | ||||||
| use serde_with::VecSkipError; |  | ||||||
| 
 |  | ||||||
| use super::TimeOverlay; |  | ||||||
| use super::{ContentRenderer, ContentsRenderer, Thumbnails, VideoListItem}; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Channel { |  | ||||||
|     pub contents: Contents, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Contents { |  | ||||||
|     pub two_column_browse_results_renderer: TabsRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct TabsRenderer { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub tabs: Vec<TabRendererWrap>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct TabRendererWrap { |  | ||||||
|     pub tab_renderer: ContentRenderer<SectionListRendererWrap>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct SectionListRendererWrap { |  | ||||||
|     pub section_list_renderer: ContentsRenderer<ItemSectionRendererWrap>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ItemSectionRendererWrap { |  | ||||||
|     pub item_section_renderer: ContentsRenderer<GridRendererWrap>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct GridRendererWrap { |  | ||||||
|     pub grid_renderer: GridRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct GridRenderer { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub items: Vec<VideoListItem<ChannelVideo>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ChannelVideo { |  | ||||||
|     pub video_id: String, |  | ||||||
|     pub thumbnail: Thumbnails, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub title: String, |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |  | ||||||
|     pub published_time_text: Option<String>, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub view_count_text: String, |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub thumbnail_overlays: Vec<TimeOverlay>, |  | ||||||
| } |  | ||||||
|  | @ -1,236 +0,0 @@ | ||||||
| pub mod channel; |  | ||||||
| pub mod player; |  | ||||||
| pub mod playlist; |  | ||||||
| pub mod playlist_music; |  | ||||||
| pub mod video; |  | ||||||
| 
 |  | ||||||
| pub use channel::Channel; |  | ||||||
| pub use player::Player; |  | ||||||
| pub use playlist::Playlist; |  | ||||||
| pub use playlist::PlaylistCont; |  | ||||||
| pub use playlist_music::PlaylistMusic; |  | ||||||
| pub use video::Video; |  | ||||||
| pub use video::VideoComments; |  | ||||||
| pub use video::VideoRecommendations; |  | ||||||
| 
 |  | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_with::{serde_as, DefaultOnError, VecSkipError}; |  | ||||||
| 
 |  | ||||||
| use crate::serializer::text::TextLink; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContentRenderer<T> { |  | ||||||
|     pub content: T, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContentsRenderer<T> { |  | ||||||
|     #[serde(alias = "tabs")] |  | ||||||
|     pub contents: Vec<T>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ThumbnailsWrap { |  | ||||||
|     pub thumbnail: Thumbnails, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Thumbnails { |  | ||||||
|     pub thumbnails: Vec<Thumbnail>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Thumbnail { |  | ||||||
|     pub url: String, |  | ||||||
|     pub width: u32, |  | ||||||
|     pub height: u32, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub enum VideoListItem<T> { |  | ||||||
|     #[serde(alias = "playlistVideoRenderer", alias = "compactVideoRenderer")] |  | ||||||
|     GridVideoRenderer { |  | ||||||
|         #[serde(flatten)] |  | ||||||
|         video: T, |  | ||||||
|     }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     ContinuationItemRenderer { |  | ||||||
|         continuation_endpoint: ContinuationEndpoint, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContinuationEndpoint { |  | ||||||
|     pub continuation_command: ContinuationCommand, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ContinuationCommand { |  | ||||||
|     pub token: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Icon { |  | ||||||
|     pub icon_type: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoOwner { |  | ||||||
|     pub video_owner_renderer: VideoOwnerRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoOwnerRenderer { |  | ||||||
|     #[serde_as(as = "crate::serializer::text::TextLink")] |  | ||||||
|     pub title: TextLink, |  | ||||||
|     pub thumbnail: Thumbnails, |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |  | ||||||
|     pub subscriber_count_text: Option<String>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub badges: Vec<UserBadge>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct UserBadge { |  | ||||||
|     pub metadata_badge_renderer: UserBadgeRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct UserBadgeRenderer { |  | ||||||
|     pub style: UserBadgeStyle, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum UserBadgeStyle { |  | ||||||
|     BadgeStyleTypeVerified, |  | ||||||
|     BadgeStyleTypeVerifiedArtist, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct TimeOverlay { |  | ||||||
|     pub thumbnail_overlay_time_status_renderer: TimeOverlayRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct TimeOverlayRenderer { |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub text: String, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub style: TimeOverlayStyle, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum TimeOverlayStyle { |  | ||||||
|     #[default] |  | ||||||
|     Default, |  | ||||||
|     Live, |  | ||||||
|     Shorts, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // YouTube Music
 |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MusicItem { |  | ||||||
|     pub thumbnail: MusicThumbnailRenderer, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub playlist_item_data: Option<PlaylistItemData>, |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub flex_columns: Vec<MusicColumn>, |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub fixed_columns: Vec<MusicColumn>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MusicThumbnailRenderer { |  | ||||||
|     #[serde(alias = "croppedSquareThumbnailRenderer")] |  | ||||||
|     pub music_thumbnail_renderer: ThumbnailsWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistItemData { |  | ||||||
|     pub video_id: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MusicContentsRenderer<T> { |  | ||||||
|     pub contents: Vec<T>, |  | ||||||
|     #[serde_as(as = "Option<VecSkipError<_>>")] |  | ||||||
|     pub continuations: Option<Vec<MusicContinuation>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| pub struct MusicColumn { |  | ||||||
|     #[serde(
 |  | ||||||
|         rename = "musicResponsiveListItemFlexColumnRenderer", |  | ||||||
|         alias = "musicResponsiveListItemFixedColumnRenderer" |  | ||||||
|     )] |  | ||||||
|     pub renderer: MusicColumnRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| pub struct MusicColumnRenderer { |  | ||||||
|     #[serde_as(as = "crate::serializer::text::TextLinks")] |  | ||||||
|     pub text: Vec<TextLink>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MusicContinuation { |  | ||||||
|     pub next_continuation_data: MusicContinuationData, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MusicContinuationData { |  | ||||||
|     pub continuation: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl Into<crate::model::Thumbnail> for Thumbnail { |  | ||||||
|     fn into(self) -> crate::model::Thumbnail { |  | ||||||
|         crate::model::Thumbnail { |  | ||||||
|             url: self.url, |  | ||||||
|             width: self.width, |  | ||||||
|             height: self.height, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl Into<Vec<crate::model::Thumbnail>> for Thumbnails { |  | ||||||
|     fn into(self) -> Vec<crate::model::Thumbnail> { |  | ||||||
|         let mut thumbnails = vec![]; |  | ||||||
|         for t in self.thumbnails { |  | ||||||
|             thumbnails.push(t.into()); |  | ||||||
|         } |  | ||||||
|         thumbnails |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,231 +0,0 @@ | ||||||
| use std::ops::Range; |  | ||||||
| 
 |  | ||||||
| use chrono::NaiveDate; |  | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_with::serde_as; |  | ||||||
| use serde_with::{json::JsonString, DefaultOnError, VecSkipError}; |  | ||||||
| 
 |  | ||||||
| use super::Thumbnails; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Player { |  | ||||||
|     pub playability_status: PlayabilityStatus, |  | ||||||
|     pub streaming_data: Option<StreamingData>, |  | ||||||
|     pub captions: Option<Captions>, |  | ||||||
|     pub video_details: Option<VideoDetails>, |  | ||||||
|     pub microformat: Option<Microformat>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(tag = "status", rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum PlayabilityStatus { |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     Ok { live_streamability: Option<Empty> }, |  | ||||||
|     /// Video cant be played because of DRM / Geoblock
 |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     Unplayable { reason: String }, |  | ||||||
|     /// Age limit / Private video
 |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     LoginRequired { reason: String }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     LiveStreamOffline { reason: String }, |  | ||||||
|     /// Video was censored / deleted
 |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     Error { reason: String }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| pub struct Empty {} |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct StreamingData { |  | ||||||
|     #[serde_as(as = "JsonString")] |  | ||||||
|     pub expires_in_seconds: u32, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub formats: Vec<Format>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub adaptive_formats: Vec<Format>, |  | ||||||
|     /// Only on livestreams
 |  | ||||||
|     pub dash_manifest_url: Option<String>, |  | ||||||
|     /// Only on livestreams
 |  | ||||||
|     pub hls_manifest_url: Option<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Format { |  | ||||||
|     pub itag: u32, |  | ||||||
|     pub url: Option<String>, |  | ||||||
| 
 |  | ||||||
|     #[serde(default, rename = "type")] |  | ||||||
|     pub format_type: FormatType, |  | ||||||
| 
 |  | ||||||
|     pub mime_type: String, |  | ||||||
| 
 |  | ||||||
|     pub bitrate: u32, |  | ||||||
| 
 |  | ||||||
|     pub width: Option<u32>, |  | ||||||
|     pub height: Option<u32>, |  | ||||||
| 
 |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::range::Range>")] |  | ||||||
|     pub index_range: Option<Range<u32>>, |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::range::Range>")] |  | ||||||
|     pub init_range: Option<Range<u32>>, |  | ||||||
| 
 |  | ||||||
|     #[serde_as(as = "JsonString")] |  | ||||||
|     pub content_length: u64, |  | ||||||
| 
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub quality: Option<Quality>, |  | ||||||
|     pub fps: Option<u8>, |  | ||||||
|     pub quality_label: Option<String>, |  | ||||||
|     pub average_bitrate: u32, |  | ||||||
|     pub color_info: Option<ColorInfo>, |  | ||||||
| 
 |  | ||||||
|     // Audio only
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|     pub audio_quality: Option<AudioQuality>, |  | ||||||
|     #[serde_as(as = "Option<JsonString>")] |  | ||||||
|     pub audio_sample_rate: Option<u32>, |  | ||||||
|     pub audio_channels: Option<u8>, |  | ||||||
|     pub loudness_db: Option<f64>, |  | ||||||
|     pub audio_track: Option<AudioTrack>, |  | ||||||
| 
 |  | ||||||
|     pub signature_cipher: Option<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl Format { |  | ||||||
|     pub fn is_audio(&self) -> bool { |  | ||||||
|         self.audio_quality.is_some() && self.audio_sample_rate.is_some() |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn is_video(&self) -> bool { |  | ||||||
|         self.quality.is_some() |  | ||||||
|             && self.quality_label.is_some() |  | ||||||
|             && self.fps.is_some() |  | ||||||
|             && self.height.is_some() |  | ||||||
|             && self.width.is_some() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] |  | ||||||
| #[serde(rename_all = "lowercase")] |  | ||||||
| pub enum Quality { |  | ||||||
|     Tiny, |  | ||||||
|     Small, |  | ||||||
|     Medium, |  | ||||||
|     Large, |  | ||||||
|     Highres, |  | ||||||
|     Hd720, |  | ||||||
|     Hd1080, |  | ||||||
|     Hd1440, |  | ||||||
|     Hd2160, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] |  | ||||||
| pub enum AudioQuality { |  | ||||||
|     #[serde(rename = "AUDIO_QUALITY_LOW", alias = "low")] |  | ||||||
|     Low, |  | ||||||
|     #[serde(rename = "AUDIO_QUALITY_MEDIUM", alias = "medium")] |  | ||||||
|     Medium, |  | ||||||
|     #[serde(rename = "AUDIO_QUALITY_HIGH", alias = "high")] |  | ||||||
|     High, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum FormatType { |  | ||||||
|     #[default] |  | ||||||
|     Default, |  | ||||||
|     /// This stream only works via DASH and not via progressive HTTP.
 |  | ||||||
|     FormatStreamTypeOtf, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Debug, Deserialize)] |  | ||||||
| #[serde(default, rename_all = "camelCase")] |  | ||||||
| pub struct ColorInfo { |  | ||||||
|     pub primaries: Primaries, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum Primaries { |  | ||||||
|     #[default] |  | ||||||
|     ColorPrimariesBt709, |  | ||||||
|     ColorPrimariesBt2020, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Debug, Deserialize)] |  | ||||||
| #[serde(default, rename_all = "camelCase")] |  | ||||||
| pub struct AudioTrack { |  | ||||||
|     pub id: String, |  | ||||||
|     pub display_name: String, |  | ||||||
|     pub audio_is_default: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Captions { |  | ||||||
|     pub player_captions_tracklist_renderer: PlayerCaptionsTracklistRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlayerCaptionsTracklistRenderer { |  | ||||||
|     pub caption_tracks: Vec<CaptionTrack>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct CaptionTrack { |  | ||||||
|     pub base_url: String, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub name: String, |  | ||||||
|     pub language_code: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoDetails { |  | ||||||
|     pub video_id: String, |  | ||||||
|     pub title: String, |  | ||||||
|     #[serde_as(as = "JsonString")] |  | ||||||
|     pub length_seconds: u32, |  | ||||||
|     pub keywords: Option<Vec<String>>, |  | ||||||
|     pub channel_id: String, |  | ||||||
|     pub short_description: Option<String>, |  | ||||||
|     pub thumbnail: Option<Thumbnails>, |  | ||||||
|     #[serde_as(as = "JsonString")] |  | ||||||
|     pub view_count: u64, |  | ||||||
|     pub author: String, |  | ||||||
|     pub is_live_content: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Microformat { |  | ||||||
|     #[serde(alias = "microformatDataRenderer")] |  | ||||||
|     pub player_microformat_renderer: PlayerMicroformatRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlayerMicroformatRenderer { |  | ||||||
|     #[serde(alias = "familySafe")] |  | ||||||
|     pub is_family_safe: bool, |  | ||||||
|     pub category: String, |  | ||||||
|     pub publish_date: NaiveDate, |  | ||||||
|     // Only on YT Music
 |  | ||||||
|     pub tags: Option<Vec<String>>, |  | ||||||
| } |  | ||||||
|  | @ -1,179 +0,0 @@ | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_with::serde_as; |  | ||||||
| use serde_with::{json::JsonString, DefaultOnError, VecSkipError}; |  | ||||||
| 
 |  | ||||||
| use crate::client2::MapResult; |  | ||||||
| use crate::serializer::text::TextLink; |  | ||||||
| 
 |  | ||||||
| use super::{ContentRenderer, ContentsRenderer, Thumbnails, ThumbnailsWrap, VideoListItem}; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Playlist { |  | ||||||
|     pub contents: Contents, |  | ||||||
|     pub header: Header, |  | ||||||
|     pub sidebar: Option<Sidebar>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistCont { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub on_response_received_actions: Vec<OnResponseReceivedAction>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Contents { |  | ||||||
|     pub two_column_browse_results_renderer: ContentsRenderer<Tab>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Tab { |  | ||||||
|     pub tab_renderer: ContentRenderer<SectionList>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct SectionList { |  | ||||||
|     pub section_list_renderer: ContentsRenderer<ItemSection>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ItemSection { |  | ||||||
|     pub item_section_renderer: ContentsRenderer<PlaylistVideoListRenderer>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistVideoListRenderer { |  | ||||||
|     pub playlist_video_list_renderer: PlaylistVideoList, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistVideoList { |  | ||||||
|     #[serde_as(as = "crate::serializer::VecLogError<_>")] |  | ||||||
|     pub contents: MapResult<Vec<VideoListItem<PlaylistVideo>>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistVideo { |  | ||||||
|     pub video_id: String, |  | ||||||
|     pub thumbnail: Thumbnails, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub title: String, |  | ||||||
|     #[serde(rename = "shortBylineText")] |  | ||||||
|     #[serde_as(as = "crate::serializer::text::TextLink")] |  | ||||||
|     pub channel: TextLink, |  | ||||||
|     #[serde_as(as = "JsonString")] |  | ||||||
|     pub length_seconds: u32, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Header { |  | ||||||
|     pub playlist_header_renderer: HeaderRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct HeaderRenderer { |  | ||||||
|     pub playlist_id: String, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub title: String, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError<Option<crate::serializer::text::Text>>")] |  | ||||||
|     pub description_text: Option<String>, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub num_videos_text: String, |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::TextLink>")] |  | ||||||
|     pub owner_text: Option<TextLink>, |  | ||||||
| 
 |  | ||||||
|     // Alternative layout
 |  | ||||||
|     pub playlist_header_banner: Option<PlaylistHeaderBanner>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub byline: Vec<Byline>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistHeaderBanner { |  | ||||||
|     pub hero_playlist_thumbnail_renderer: ThumbnailsWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Byline { |  | ||||||
|     pub playlist_byline_renderer: BylineRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct BylineRenderer { |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub text: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Sidebar { |  | ||||||
|     pub playlist_sidebar_renderer: SidebarRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct SidebarRenderer { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub items: Vec<SidebarItemPrimary>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct SidebarItemPrimary { |  | ||||||
|     pub playlist_sidebar_primary_info_renderer: SidebarPrimaryInfoRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct SidebarPrimaryInfoRenderer { |  | ||||||
|     pub thumbnail_renderer: PlaylistThumbnailRenderer, |  | ||||||
|     // - `"495", " videos"`
 |  | ||||||
|     // - `"3,310,996 views"`
 |  | ||||||
|     // - `"Last updated on ", "Aug 7, 2022"`
 |  | ||||||
|     #[serde_as(as = "Vec<crate::serializer::text::Text>")] |  | ||||||
|     pub stats: Vec<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistThumbnailRenderer { |  | ||||||
|     // the alternative field name is used by YTM playlists
 |  | ||||||
|     #[serde(alias = "playlistCustomThumbnailRenderer")] |  | ||||||
|     pub playlist_video_thumbnail_renderer: ThumbnailsWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct OnResponseReceivedAction { |  | ||||||
|     pub append_continuation_items_action: AppendAction, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AppendAction { |  | ||||||
|     #[serde_as(as = "crate::serializer::VecLogError<_>")] |  | ||||||
|     pub continuation_items: MapResult<Vec<VideoListItem<PlaylistVideo>>>, |  | ||||||
|     pub target_id: String, |  | ||||||
| } |  | ||||||
|  | @ -1,95 +0,0 @@ | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_with::serde_as; |  | ||||||
| use serde_with::VecSkipError; |  | ||||||
| 
 |  | ||||||
| use crate::serializer::text::Text; |  | ||||||
| 
 |  | ||||||
| use super::MusicThumbnailRenderer; |  | ||||||
| use super::{ |  | ||||||
|     ContentRenderer, ContentsRenderer, MusicContentsRenderer, MusicContinuation, MusicItem, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistMusic { |  | ||||||
|     pub contents: Contents, |  | ||||||
|     pub header: Header, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Contents { |  | ||||||
|     pub single_column_browse_results_renderer: ContentsRenderer<Tab>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Tab { |  | ||||||
|     pub tab_renderer: ContentRenderer<SectionList>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct SectionList { |  | ||||||
|     /// Includes a continuation token for fetching recommendations
 |  | ||||||
|     pub section_list_renderer: MusicContentsRenderer<ItemSection>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ItemSection { |  | ||||||
|     #[serde(alias = "musicPlaylistShelfRenderer")] |  | ||||||
|     pub music_shelf_renderer: MusicShelf, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MusicShelf { |  | ||||||
|     /// Playlist ID (only for playlists)
 |  | ||||||
|     pub playlist_id: Option<String>, |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub contents: Vec<PlaylistMusicItem>, |  | ||||||
|     /// Continuation token for fetching more (>100) playlist items
 |  | ||||||
|     #[serde_as(as = "Option<VecSkipError<_>>")] |  | ||||||
|     pub continuations: Option<Vec<MusicContinuation>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct PlaylistMusicItem { |  | ||||||
|     pub music_responsive_list_item_renderer: MusicItem, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Header { |  | ||||||
|     pub music_detail_header_renderer: HeaderRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct HeaderRenderer { |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub title: String, |  | ||||||
|     /// Content type + Channel/Artist + Year.
 |  | ||||||
|     /// Missing on artist_tracks view.
 |  | ||||||
|     ///
 |  | ||||||
|     /// `"Playlist", " • ", <"Best Music">, " • ", "2022"`
 |  | ||||||
|     ///
 |  | ||||||
|     /// `"Album", " • ", <"Helene Fischer">, " • ", "2021"`
 |  | ||||||
|     pub subtitle: Option<Text>, |  | ||||||
|     /// Playlist description. May contain hashtags which are
 |  | ||||||
|     /// displayed as search links on the YouTube website.
 |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |  | ||||||
|     pub description: Option<String>, |  | ||||||
|     /// Playlist thumbnail / album cover.
 |  | ||||||
|     /// Missing on artist_tracks view.
 |  | ||||||
|     pub thumbnail: Option<MusicThumbnailRenderer>, |  | ||||||
|     /// Number of tracks + playtime.
 |  | ||||||
|     /// Missing on artist_tracks view.
 |  | ||||||
|     ///
 |  | ||||||
|     /// `"64 songs", " • ", "3 hours, 40 minutes"`
 |  | ||||||
|     pub second_subtitle: Option<Text>, |  | ||||||
| } |  | ||||||
|  | @ -1,432 +0,0 @@ | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_with::serde_as; |  | ||||||
| use serde_with::{DefaultOnError, VecSkipError}; |  | ||||||
| 
 |  | ||||||
| use crate::serializer::text::TextLink; |  | ||||||
| 
 |  | ||||||
| use super::{ContinuationEndpoint, Icon, Thumbnails, VideoListItem, VideoOwner}; |  | ||||||
| 
 |  | ||||||
| /// Video info response
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Video { |  | ||||||
|     pub contents: Contents, |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub engagement_panels: Vec<EngagementPanel>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Video recommendations response
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoRecommendations { |  | ||||||
|     pub on_response_received_endpoints: Vec<RecommendationsContItem>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /// Video comments response
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoComments { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub on_response_received_endpoints: Vec<CommentsContItem>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Contents { |  | ||||||
|     pub two_column_watch_next_results: TwoColumnWatchNextResults, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct TwoColumnWatchNextResults { |  | ||||||
|     pub results: VideoResultsWrap, |  | ||||||
|     pub secondary_results: RecommendationResultsWrap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoResultsWrap { |  | ||||||
|     pub results: VideoResults, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoResults { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub contents: Vec<VideoResultsItem>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub enum VideoResultsItem { |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     VideoPrimaryInfoRenderer { |  | ||||||
|         #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|         title: String, |  | ||||||
|         view_count: ViewCountWrap, |  | ||||||
|         video_actions: VideoActions, |  | ||||||
|         #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|         date_text: String, |  | ||||||
|     }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     VideoSecondaryInfoRenderer { |  | ||||||
|         owner: VideoOwner, |  | ||||||
|         #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|         description: String, |  | ||||||
|         #[serde(default)] |  | ||||||
|         #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|         metadata_row_container: Option<MetadataRowContainer>, |  | ||||||
|     }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     ItemSectionRenderer { |  | ||||||
|         #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|         contents: Vec<ItemSection>, |  | ||||||
|         section_identifier: String, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ViewCountWrap { |  | ||||||
|     pub video_view_count_renderer: ViewCount, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ViewCount { |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub view_count: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoActions { |  | ||||||
|     pub menu_renderer: VideoActionsMenu, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoActionsMenu { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub top_level_buttons: Vec<ToggleButtonWrap>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ToggleButtonWrap { |  | ||||||
|     pub toggle_button_renderer: ToggleButton, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct ToggleButton { |  | ||||||
|     pub default_icon: Icon, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub default_text: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MetadataRowContainer { |  | ||||||
|     pub metadata_row_container_renderer: MetadataRowContainerRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MetadataRowContainerRenderer { |  | ||||||
|     pub rows: Vec<MetadataRow>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MetadataRow { |  | ||||||
|     pub metadata_row_renderer: MetadataRowRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct MetadataRowRenderer { |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub title: String, |  | ||||||
|     #[serde_as(as = "Vec<crate::serializer::text::TextLinks>")] |  | ||||||
|     pub contents: Vec<Vec<TextLink>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub enum ItemSection { |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     CommentsEntryPointHeaderRenderer { |  | ||||||
|         #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|         header_text: String, |  | ||||||
|         #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|         comment_count: String, |  | ||||||
|     }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     ContinuationItemRenderer { |  | ||||||
|         continuation_endpoint: ContinuationEndpoint, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RecommendationResultsWrap { |  | ||||||
|     pub secondary_results: RecommendationResults, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RecommendationResults { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub results: Vec<VideoListItem<RecommendedVideo>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RecommendedVideo { |  | ||||||
|     pub video_id: String, |  | ||||||
|     pub thumbnail: Thumbnails, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub title: String, |  | ||||||
|     #[serde(rename = "shortBylineText")] |  | ||||||
|     #[serde_as(as = "crate::serializer::text::TextLink")] |  | ||||||
|     pub channel: TextLink, |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |  | ||||||
|     pub length_text: Option<String>, |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |  | ||||||
|     pub published_time_text: Option<String>, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub view_count_text: String, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub badges: Vec<VideoBadge>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoBadge { |  | ||||||
|     pub metadata_badge_renderer: VideoBadgeRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct VideoBadgeRenderer { |  | ||||||
|     pub style: VideoBadgeStyle, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum VideoBadgeStyle { |  | ||||||
|     BadgeStyleTypeLiveNow, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct EngagementPanel { |  | ||||||
|     pub engagement_panel_section_list_renderer: EngagementPanelRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct EngagementPanelRenderer { |  | ||||||
|     pub header: EngagementPanelHeader, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct EngagementPanelHeader { |  | ||||||
|     pub engagement_panel_title_header_renderer: EngagementPanelHeaderRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct EngagementPanelHeaderRenderer { |  | ||||||
|     pub menu: EngagementPanelMenu, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct EngagementPanelMenu { |  | ||||||
|     pub sort_filter_sub_menu_renderer: EngagementPanelMenuRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct EngagementPanelMenuRenderer { |  | ||||||
|     pub sub_menu_items: Vec<EngagementPanelMenuItem>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct EngagementPanelMenuItem { |  | ||||||
|     pub service_endpoint: ContinuationEndpoint, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RecommendationsContItem { |  | ||||||
|     pub append_continuation_items_action: AppendRecommendations, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AppendRecommendations { |  | ||||||
|     pub continuation_items: Vec<VideoListItem<RecommendedVideo>>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct CommentsContItem { |  | ||||||
|     #[serde(alias = "reloadContinuationItemsCommand")] |  | ||||||
|     pub append_continuation_items_action: AppendComments, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AppendComments { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub continuation_items: Vec<CommentListItem>, |  | ||||||
|     pub target_id: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub enum CommentListItem { |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     CommentThreadRenderer { |  | ||||||
|         comment: Comment, |  | ||||||
|         #[serde(default)] |  | ||||||
|         replies: Replies, |  | ||||||
|         #[serde(default)] |  | ||||||
|         #[serde_as(deserialize_as = "DefaultOnError")] |  | ||||||
|         rendering_priority: CommentPriority, |  | ||||||
|     }, |  | ||||||
|     CommentRenderer { |  | ||||||
|         #[serde(flatten)] |  | ||||||
|         comment: CommentRenderer, |  | ||||||
|     }, |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     ContinuationItemRenderer { |  | ||||||
|         continuation_endpoint: ContinuationEndpoint, |  | ||||||
|     }, |  | ||||||
| 
 |  | ||||||
|     // TODO: TMP
 |  | ||||||
|     #[serde(rename_all = "camelCase")] |  | ||||||
|     CommentsHeaderRenderer { count_text: Option<String> }, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Comment { |  | ||||||
|     pub comment_renderer: CommentRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct CommentRenderer { |  | ||||||
|     // There may be comments with missing authors (possibly deleted users?)
 |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError<Option<crate::serializer::text::Text>>")] |  | ||||||
|     pub author_text: Option<String>, |  | ||||||
|     pub author_thumbnail: Thumbnails, |  | ||||||
|     #[serde(default)] |  | ||||||
|     #[serde_as(as = "DefaultOnError")] |  | ||||||
|     pub author_endpoint: Option<AuthorEndpoint>, |  | ||||||
| 
 |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub content_text: String, |  | ||||||
|     #[serde_as(as = "crate::serializer::text::Text")] |  | ||||||
|     pub published_time_text: String, |  | ||||||
|     pub comment_id: String, |  | ||||||
|     pub author_is_channel_owner: bool, |  | ||||||
|     #[serde_as(as = "Option<crate::serializer::text::Text>")] |  | ||||||
|     pub vote_count: Option<String>, |  | ||||||
|     pub author_comment_badge: Option<AuthorCommentBadge>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub reply_count: u32, |  | ||||||
|     pub action_buttons: CommentActionButtons, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AuthorEndpoint { |  | ||||||
|     pub browse_endpoint: BrowseEndpoint, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct BrowseEndpoint { |  | ||||||
|     pub browse_id: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] |  | ||||||
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |  | ||||||
| pub enum CommentPriority { |  | ||||||
|     #[default] |  | ||||||
|     RenderingPriorityUnknown, |  | ||||||
|     RenderingPriorityPinnedComment, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Default, Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct Replies { |  | ||||||
|     pub comment_replies_renderer: RepliesRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[serde_as] |  | ||||||
| #[derive(Default, Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct RepliesRenderer { |  | ||||||
|     #[serde_as(as = "VecSkipError<_>")] |  | ||||||
|     pub contents: Vec<CommentListItem>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct CommentActionButtons { |  | ||||||
|     pub comment_action_buttons_renderer: CommentActionButtonsRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct CommentActionButtonsRenderer { |  | ||||||
|     pub creator_heart: Option<CreatorHeart>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct CreatorHeart { |  | ||||||
|     pub creator_heart_renderer: CreatorHeartRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct CreatorHeartRenderer { |  | ||||||
|     pub is_hearted: bool, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AuthorCommentBadge { |  | ||||||
|     pub author_comment_badge_renderer: AuthorCommentBadgeRenderer, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Debug, Deserialize)] |  | ||||||
| #[serde(rename_all = "camelCase")] |  | ||||||
| pub struct AuthorCommentBadgeRenderer { |  | ||||||
|     pub icon: Icon, |  | ||||||
| } |  | ||||||
|  | @ -1,334 +0,0 @@ | ||||||
| --- |  | ||||||
| source: src/client/player.rs |  | ||||||
| expression: player_data |  | ||||||
| --- |  | ||||||
| info: |  | ||||||
|   id: pPvd8UxmSbQ |  | ||||||
|   title: Inspiring Cinematic Uplifting (Creative Commons) |  | ||||||
|   description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk" |  | ||||||
|   length: 163 |  | ||||||
|   thumbnails: |  | ||||||
|     - url: "https://i.ytimg.com/vi_webp/pPvd8UxmSbQ/default.webp" |  | ||||||
|       width: 120 |  | ||||||
|       height: 90 |  | ||||||
|     - url: "https://i.ytimg.com/vi_webp/pPvd8UxmSbQ/mqdefault.webp" |  | ||||||
|       width: 320 |  | ||||||
|       height: 180 |  | ||||||
|     - url: "https://i.ytimg.com/vi_webp/pPvd8UxmSbQ/hqdefault.webp" |  | ||||||
|       width: 480 |  | ||||||
|       height: 360 |  | ||||||
|     - url: "https://i.ytimg.com/vi_webp/pPvd8UxmSbQ/sddefault.webp" |  | ||||||
|       width: 640 |  | ||||||
|       height: 480 |  | ||||||
|   channel: |  | ||||||
|     id: UCbxxEi-ImPlbLx5F-fHetEg |  | ||||||
|     name: RomanSenykMusic - Royalty Free Music |  | ||||||
|   publish_date: "~" |  | ||||||
|   view_count: 426567 |  | ||||||
|   keywords: |  | ||||||
|     - no copyright music |  | ||||||
|     - background music |  | ||||||
|     - copyright free music |  | ||||||
|     - non copyrighted music |  | ||||||
|     - free music |  | ||||||
|     - no copyright music cinematic |  | ||||||
|     - inspiring music |  | ||||||
|     - inspiring background music |  | ||||||
|     - cinematic music |  | ||||||
|     - cinematic background music |  | ||||||
|     - no copyright music inspiring |  | ||||||
|     - free music no copyright |  | ||||||
|     - uplifting music |  | ||||||
|     - trailer music no copyright |  | ||||||
|     - trailer music |  | ||||||
|     - download music |  | ||||||
|     - free background music |  | ||||||
|     - orchestral music |  | ||||||
|     - romansenykmusic |  | ||||||
|     - motivational music |  | ||||||
|     - montage music |  | ||||||
|   category: ~ |  | ||||||
|   is_live_content: false |  | ||||||
|   is_family_safe: ~ |  | ||||||
| video_streams: |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=1619781&dur=163.143&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=17&lmt=1580005480199246&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2F3gpp&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAJ2s7Pm4w42X3u3PWYViDeqIaE2tE9J6oIGpd0KB9gFsAiAH84QaJ4oUNivdRDUBi1ZYI7JSxESsPQ53mLInajKzcQ%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 17 |  | ||||||
|     bitrate: 79452 |  | ||||||
|     average_bitrate: 79428 |  | ||||||
|     size: 1619781 |  | ||||||
|     index_range: ~ |  | ||||||
|     init_range: ~ |  | ||||||
|     width: 176 |  | ||||||
|     height: 144 |  | ||||||
|     fps: 7 |  | ||||||
|     quality: 144p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/3gpp; codecs=\"mp4v.20.3, mp4a.40.2\"" |  | ||||||
|     format: 3gp |  | ||||||
|     codec: mp4v |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=11439331&dur=163.096&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=18&lmt=1580005476071743&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&pl=37&ratebypass=yes&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAJAH-tWof01vrs8phEoz51XkWwdMzQ77k1UTrdY5XiuTAiA38z-qANX0jtfCiAl4EVMZaKo1ncrzJFRrCffZ6LagrA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 18 |  | ||||||
|     bitrate: 561339 |  | ||||||
|     average_bitrate: 561109 |  | ||||||
|     size: 11439331 |  | ||||||
|     index_range: ~ |  | ||||||
|     init_range: ~ |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
| video_only_streams: |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=1224002&dur=163.029&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=394&keepalive=yes&lmt=1608045375671513&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgYCPleG9F86UoDRvzFL2xSUUI-HLZGw_P7qBOixlcmKsCIQChdmrJ1NvKo5Ra4QJ9ivR5V8fEcQs0f_3aUiqMhGB5DQ%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 394 |  | ||||||
|     bitrate: 67637 |  | ||||||
|     average_bitrate: 60063 |  | ||||||
|     size: 1224002 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 256 |  | ||||||
|     height: 144 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 144p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.00M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=2238952&dur=163.029&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=395&keepalive=yes&lmt=1608045728968690&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAKCXHOCh_P3VlNWebTeWw0WdSln-zYe3BjZeEm2QiltCAiAQNcJBI4G-8dK5z1IUoqBZctk6ddjkl_QYKRFAKXyOcw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 395 |  | ||||||
|     bitrate: 135747 |  | ||||||
|     average_bitrate: 109867 |  | ||||||
|     size: 2238952 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 426 |  | ||||||
|     height: 240 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 240p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.00M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=7808990&dur=163.029&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=134&keepalive=yes&lmt=1580005649163759&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAIjjrMvCEzSLlbvbrjItT4V9JdpggnO5IHye9i4PxTyzAiAmbaFCB2hH7evf9JX3JUx-tU9S6zv2IzSKz8ObGSVRjw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 134 |  | ||||||
|     bitrate: 538143 |  | ||||||
|     average_bitrate: 383195 |  | ||||||
|     size: 7808990 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.4d401e\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=4130385&dur=163.029&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=396&keepalive=yes&lmt=1608045761576250&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgBrQhbygTP6RGjUk0lGbxBI5e3NdeR6C_SW8R_ckZ2PkCIQDaBg5cJxYVWfwRrrELQFgRMOJ4xS3oOOROayoQMjxaCA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 396 |  | ||||||
|     bitrate: 258097 |  | ||||||
|     average_bitrate: 202682 |  | ||||||
|     size: 4130385 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.01M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=6873325&dur=163.029&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=397&keepalive=yes&lmt=1608045990917419&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAMqBb1hKVVzWl3Awrh1T8GQG9IrSWF84zW_ZfjgbAN5QAiAaP3jYyI4ox2aclcOCzYFzqWgByWCxj_FgTN-SfsARXw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 397 |  | ||||||
|     bitrate: 436843 |  | ||||||
|     average_bitrate: 337281 |  | ||||||
|     size: 6873325 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 854 |  | ||||||
|     height: 480 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 480p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.04M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=22365208&dur=163.046&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=398&keepalive=yes&lmt=1608048380553749&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgR6KqCOoig_FMl2tWKa7qHSmCjIZa9S7ABzEI16qdO2sCIFXccwql4bqV9CHlqXY4tgxyMFUsp7vW4XUjxs3AyG6H&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 398 |  | ||||||
|     bitrate: 1348419 |  | ||||||
|     average_bitrate: 1097369 |  | ||||||
|     size: 22365208 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 720p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.08M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=65400181&dur=163.046&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=299&keepalive=yes&lmt=1580005649161486&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAPjxbuzkozPDc1Nd_0q5X8x8H2SiDvAUFuqqMadtz3SNAiEA_3kXCeePb2kci-WB2779tzI56E6E0iKwoHnUSkKCzwU%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 299 |  | ||||||
|     bitrate: 4190323 |  | ||||||
|     average_bitrate: 3208919 |  | ||||||
|     size: 65400181 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.64002a\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=42567727&dur=163.046&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=399&keepalive=yes&lmt=1608052932785283&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgFguw-cmBNOQegpyRRzcCScp2WaSnq_o7FB1-AiBgFpICIAGlMj9-kzNCWb3nhpg98Mc239ls6YYyoL8z1QpM8VmL&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 399 |  | ||||||
|     bitrate: 2572342 |  | ||||||
|     average_bitrate: 2088624 |  | ||||||
|     size: 42567727 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.09M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
| audio_streams: |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=995840&dur=163.189&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=139&keepalive=yes&lmt=1580005582214385&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhALhtrbIL9_CQBeXsEwxFqyPY1XqBCOceQc7y00h7XBS9AiAH9VkMnkuFCU1ACkYU__uApTwcYeDoYNU74VYmKED3Gw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 139 |  | ||||||
|     bitrate: 49724 |  | ||||||
|     average_bitrate: 48818 |  | ||||||
|     size: 995840 |  | ||||||
|     index_range: |  | ||||||
|       start: 641 |  | ||||||
|       end: 876 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 640 |  | ||||||
|     mime: "audio/mp4; codecs=\"mp4a.40.5\"" |  | ||||||
|     format: m4a |  | ||||||
|     codec: mp4a |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=934449&dur=163.061&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=249&keepalive=yes&lmt=1608509101590706&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgPmadKd9As393GMRmu1Ow4RfQkDQhY6SbPRnkLMYyZOoCIE9AIgMMJ7n5HD2gKv3c8-HrnkMeakq_uWUOivnWquJX&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 249 |  | ||||||
|     bitrate: 53039 |  | ||||||
|     average_bitrate: 45845 |  | ||||||
|     size: 934449 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=1245866&dur=163.061&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=250&keepalive=yes&lmt=1608509101111096&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIge8uetzhejNg3DegY9EQkpvVam1gp8Jm-q6oqtb6Rn9wCIQD_VeQle7Z2H1uXB6qsYMGDU4OWA4h6YTTwMDmw5DDvuA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 250 |  | ||||||
|     bitrate: 71268 |  | ||||||
|     average_bitrate: 61123 |  | ||||||
|     size: 1245866 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=2640283&dur=163.096&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=140&keepalive=yes&lmt=1580005579712232&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAI8YylDImOPxxRo251u_RX6ir_j0p-gP_yQPcI6SxareAiArCxOcgrF9pxYS5bNYEnLGEQxRiEFJ0sT2Ydpa1G7x5A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 140 |  | ||||||
|     bitrate: 130268 |  | ||||||
|     average_bitrate: 129508 |  | ||||||
|     size: 2640283 |  | ||||||
|     index_range: |  | ||||||
|       start: 632 |  | ||||||
|       end: 867 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 631 |  | ||||||
|     mime: "audio/mp4; codecs=\"mp4a.40.2\"" |  | ||||||
|     format: m4a |  | ||||||
|     codec: mp4a |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=2476314&dur=163.061&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=251&keepalive=yes&lmt=1608509101894140&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgErpt4HOgIybMGrMD2qg9JB4O53n0jsCxkiI9JBxbz8ECIQDixyFJ54m4NsxhyFtIYPscMVp_G6RyvwrfKzdoya-62Q%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 251 |  | ||||||
|     bitrate: 140633 |  | ||||||
|     average_bitrate: 121491 |  | ||||||
|     size: 2476314 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
| subtitles: |  | ||||||
|   - url: "https://www.youtube.com/api/timedtext?v=pPvd8UxmSbQ&caps=asr&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1659484955&sparams=ip,ipbits,expire,v,caps,xoaf&signature=EBFE9BB6A8D01D674157DE1F45A3BEDCAB35496B.3ED931CA7233F0FF5B19062AA22F803CC3D78215&key=yt8&lang=en&fmt=srv3" |  | ||||||
|     lang: en |  | ||||||
|     lang_name: English |  | ||||||
|     auto_generated: false |  | ||||||
| expires_in_seconds: 21540 |  | ||||||
| 
 |  | ||||||
|  | @ -1,445 +0,0 @@ | ||||||
| --- |  | ||||||
| source: src/client/player.rs |  | ||||||
| expression: player_data |  | ||||||
| --- |  | ||||||
| info: |  | ||||||
|   id: pPvd8UxmSbQ |  | ||||||
|   title: Inspiring Cinematic Uplifting (Creative Commons) |  | ||||||
|   description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk" |  | ||||||
|   length: 163 |  | ||||||
|   thumbnails: |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBSNHImLtGal2a95M5oyTT_uuTZlw" |  | ||||||
|       width: 168 |  | ||||||
|       height: 94 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLANEm-jLrG1ec9hFpfa2rovJR4uqg" |  | ||||||
|       width: 196 |  | ||||||
|       height: 110 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA4_mATyxRz68LdA8oWuPKlS2gIUw" |  | ||||||
|       width: 246 |  | ||||||
|       height: 138 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB0IUWkEMwrYEtP_4pX2uuOBv1JPA" |  | ||||||
|       width: 336 |  | ||||||
|       height: 188 |  | ||||||
|     - url: "https://i.ytimg.com/vi_webp/pPvd8UxmSbQ/maxresdefault.webp" |  | ||||||
|       width: 1920 |  | ||||||
|       height: 1080 |  | ||||||
|   channel: |  | ||||||
|     id: UCbxxEi-ImPlbLx5F-fHetEg |  | ||||||
|     name: RomanSenykMusic - Royalty Free Music |  | ||||||
|   publish_date: "2019-05-30T00:00:00" |  | ||||||
|   view_count: 426567 |  | ||||||
|   keywords: |  | ||||||
|     - no copyright music |  | ||||||
|     - background music |  | ||||||
|     - copyright free music |  | ||||||
|     - non copyrighted music |  | ||||||
|     - free music |  | ||||||
|     - no copyright music cinematic |  | ||||||
|     - inspiring music |  | ||||||
|     - inspiring background music |  | ||||||
|     - cinematic music |  | ||||||
|     - cinematic background music |  | ||||||
|     - no copyright music inspiring |  | ||||||
|     - free music no copyright |  | ||||||
|     - uplifting music |  | ||||||
|     - trailer music no copyright |  | ||||||
|     - trailer music |  | ||||||
|     - download music |  | ||||||
|     - free background music |  | ||||||
|     - orchestral music |  | ||||||
|     - romansenykmusic |  | ||||||
|     - motivational music |  | ||||||
|     - montage music |  | ||||||
|   category: Music |  | ||||||
|   is_live_content: false |  | ||||||
|   is_family_safe: true |  | ||||||
| video_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=11439331&dur=163.096&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=18&lmt=1580005476071743&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=ig_QojS86GYHYg&ns=cOm8mnsR9HFwfq55dDyGyqYH&pl=37&ratebypass=yes&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgNqstD2C4HG7Vn5En5Z4aUyH2mk7gAB9cyfOAWGCaWeoCIQDbxxJZuOnz_3RJNviFYADvgTO7u8YBYKtpFtp9Ujmk2w%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=2211222&vprv=1" |  | ||||||
|     itag: 18 |  | ||||||
|     bitrate: 561339 |  | ||||||
|     average_bitrate: 561109 |  | ||||||
|     size: 11439331 |  | ||||||
|     index_range: ~ |  | ||||||
|     init_range: ~ |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
| video_only_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=1484736&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=278&keepalive=yes&lmt=1608509388295661&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgDYs7xrSqi-Co90zypk9zutEJ-aaEpNAHWnE57zVIfxgCIQDE0exs9SN8JH5OEJ8728Ke6bfa0CsUucFETHLk3IFF7w%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 278 |  | ||||||
|     bitrate: 87458 |  | ||||||
|     average_bitrate: 72857 |  | ||||||
|     size: 1484736 |  | ||||||
|     index_range: |  | ||||||
|       start: 218 |  | ||||||
|       end: 751 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 217 |  | ||||||
|     width: 256 |  | ||||||
|     height: 144 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 144p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=1224002&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=394&keepalive=yes&lmt=1608045375671513&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAI-uoNLUkMHpH35niVh1tBvwwFLtmSbeHyknmyCvccFVAiB2XriyJd0u2q-tGIRTx5qtKt6bJCs5ndXtMsdSxOheuA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 394 |  | ||||||
|     bitrate: 67637 |  | ||||||
|     average_bitrate: 60063 |  | ||||||
|     size: 1224002 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 256 |  | ||||||
|     height: 144 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 144p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.00M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=2973283&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=242&keepalive=yes&lmt=1608509388282028&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgEleuqkeo7x7BsHur5aGPfHaT6KjKEG4c1d_xXwqlrsYCIQD85X_m050XwWyYlfLiWtZz-TX--H8H0UvfZCWKpY7m4Q%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 242 |  | ||||||
|     bitrate: 184064 |  | ||||||
|     average_bitrate: 145902 |  | ||||||
|     size: 2973283 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 753 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 426 |  | ||||||
|     height: 240 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 240p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=2238952&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=395&keepalive=yes&lmt=1608045728968690&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAIBttTR02kTdGb4vdxQ9Gro88JOAY7u5z69nJbdmVS1sAiBr61rqkUtra4PHLdnp2w-s8ZSaN_4qZ3OEeeuIr5C13w%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 395 |  | ||||||
|     bitrate: 135747 |  | ||||||
|     average_bitrate: 109867 |  | ||||||
|     size: 2238952 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 426 |  | ||||||
|     height: 240 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 240p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.00M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=7808990&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=134&keepalive=yes&lmt=1580005649163759&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAMBRhMAZ5GXFSZHN6D-XhXRdG_EWSNwnN2eLPlwVNQ6PAiEA75eH0iJLgwRkujaABZnaJxG2ni-4irYHEGD42x6uaQg%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=2211222&vprv=1" |  | ||||||
|     itag: 134 |  | ||||||
|     bitrate: 538143 |  | ||||||
|     average_bitrate: 383195 |  | ||||||
|     size: 7808990 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.4d401e\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=5169510&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=243&keepalive=yes&lmt=1608509388282405&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgNi0fwQbep6oKsEeEGfms2Ay4x2OL2G0hUX5GFhycgKkCIANiC-j-Gz3-noxsNeSKKPxy--T9mFBu_8V7Vi5-zDYS&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 243 |  | ||||||
|     bitrate: 319085 |  | ||||||
|     average_bitrate: 253673 |  | ||||||
|     size: 5169510 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=4130385&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=396&keepalive=yes&lmt=1608045761576250&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgFuBoOIkqwq0D1_OmnNJx3C0jmhHUyskpzPrTMoaWRYECIFZ1Y4QbQ41GsWS8yRHox8l_nGVosfXhXfKu3v18AyeT&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 396 |  | ||||||
|     bitrate: 258097 |  | ||||||
|     average_bitrate: 202682 |  | ||||||
|     size: 4130385 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.01M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=8890590&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=244&keepalive=yes&lmt=1608509388284632&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgMYF0KQQNgYI8oOhgdCwyRY6E_hvFnJiaAadyMf89MRoCIHnDnROTvUoy0iIBM3MzFAxJh_bLA-2vFl9KFDrHOf1B&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 244 |  | ||||||
|     bitrate: 539056 |  | ||||||
|     average_bitrate: 436270 |  | ||||||
|     size: 8890590 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 854 |  | ||||||
|     height: 480 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 480p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=6873325&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=397&keepalive=yes&lmt=1608045990917419&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAOtLGFoFtLHIXzNRoSrR7ULbIz91OYmaVQkcSatqNKAiAiEA23ZF7h2BZZCAGc0Zdd2p3PWRotmwLDyH6yYCuQpE8xw%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 397 |  | ||||||
|     bitrate: 436843 |  | ||||||
|     average_bitrate: 337281 |  | ||||||
|     size: 6873325 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 854 |  | ||||||
|     height: 480 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 480p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.04M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=16547577&dur=163.029&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=247&keepalive=yes&lmt=1608509388326822&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgfYKbT_196P-2EtjuqcTKdataiM480y65Ko0a73dv7WECIQC6nqWienQvu7swC1OW9HlwFWRH7VwTwj6H4yjY6FYvzg%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 247 |  | ||||||
|     bitrate: 982813 |  | ||||||
|     average_bitrate: 812006 |  | ||||||
|     size: 16547577 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 720p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=35955780&dur=163.046&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=302&keepalive=yes&lmt=1608509234088626&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgQG8GPj3w_5_Lr2apagmte66IFBY3bYcZ2KnhwnUpshYCIFgvHYIZsz8WdYGSk9adpfMNKX0pzSP_l8cW47Gq2RTi&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 302 |  | ||||||
|     bitrate: 2354009 |  | ||||||
|     average_bitrate: 1764202 |  | ||||||
|     size: 35955780 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 771 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 720p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=22365208&dur=163.046&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=398&keepalive=yes&lmt=1608048380553749&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAI-VhcBU6o8LGmeuVYC2_zbxeGvC6XWf7yIOQ1RvjURhAiEA0YcZlVOI2ZUtKl-31__Hzax2SOUPeekCRjqjfw4m15s%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 398 |  | ||||||
|     bitrate: 1348419 |  | ||||||
|     average_bitrate: 1097369 |  | ||||||
|     size: 22365208 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 720p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.08M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=65400181&dur=163.046&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=299&keepalive=yes&lmt=1580005649161486&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAIdbG-deTvLhp7mD2b-QZYQamPFv75l1bNBEEOMihrxPAiEA1NYvRlFphbRRvFIBCP-Ij9-5q8OTwUskgsL6LyIrD7c%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=2211222&vprv=1" |  | ||||||
|     itag: 299 |  | ||||||
|     bitrate: 4190323 |  | ||||||
|     average_bitrate: 3208919 |  | ||||||
|     size: 65400181 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.64002a\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=62993617&dur=163.046&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=303&keepalive=yes&lmt=1608509371758331&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAJ8n34LQhg6iEg1Ux9rDkk48e8l3vBR4WwuHeIpKnorlAiBopK4z-nq-pJTPTmrdbbKPW1Lfufdz2f9sGUKY-dzk5A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 303 |  | ||||||
|     bitrate: 3832648 |  | ||||||
|     average_bitrate: 3090839 |  | ||||||
|     size: 62993617 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 776 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=WEB&clen=42567727&dur=163.046&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=399&keepalive=yes&lmt=1608052932785283&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAMewAT3SgJRGn7wqDaDzNWcsAfrjFRu6k0wm7O_5YJeQAiANVhGmILp_gmNXnmixDesxsZ44_72YBT2SqjLLSZV32w%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 399 |  | ||||||
|     bitrate: 2572342 |  | ||||||
|     average_bitrate: 2088624 |  | ||||||
|     size: 42567727 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.09M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
| audio_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=934449&dur=163.061&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=249&keepalive=yes&lmt=1608509101590706&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAOdVu1woKNveQspV4WPm1PHrOBuzlrnPu2ZLvyiYZCSbAiAYODN_y5t1oU334SHUqqgyc4Wnt-9If-W98Fd966fy2A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 249 |  | ||||||
|     bitrate: 53039 |  | ||||||
|     average_bitrate: 45845 |  | ||||||
|     size: 934449 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=1245866&dur=163.061&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=250&keepalive=yes&lmt=1608509101111096&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAMJJ-uGQureE70LIxHjHP9hFxqcWwsSlxXX6EjGKmFfEAiAvQ98YKkqUrweNnBZOI7pXJk1kuU_1hSsQ0KeNU4CbyQ%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 250 |  | ||||||
|     bitrate: 71268 |  | ||||||
|     average_bitrate: 61123 |  | ||||||
|     size: 1245866 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=2640283&dur=163.096&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=140&keepalive=yes&lmt=1580005579712232&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fmp4&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhANXIw4pIwIPvMGWnJSrA_bnmBX6KPBPqak18aPtKsI8jAiBvisRnEtFax7OTrwKbOiktCihoMraLkCi7Rnnu6JGmeQ%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=2211222&vprv=1" |  | ||||||
|     itag: 140 |  | ||||||
|     bitrate: 130268 |  | ||||||
|     average_bitrate: 129508 |  | ||||||
|     size: 2640283 |  | ||||||
|     index_range: |  | ||||||
|       start: 632 |  | ||||||
|       end: 867 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 631 |  | ||||||
|     mime: "audio/mp4; codecs=\"mp4a.40.2\"" |  | ||||||
|     format: m4a |  | ||||||
|     codec: mp4a |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=2476314&dur=163.061&ei=q1jpYtq3BJCX1gKVyJGQDg&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AGfBIFoT5D_NZAwXN7lVCS2VYLDMMegfaJQqvSJp-Hhy&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=251&keepalive=yes&lmt=1608509101894140&lsig=AG3C_xAwRgIhANxHzq0WC6OvdTpPJp52z3eGAm-jzUX7fcKiWlJ0T9kEAiEA02Bjesi_an2-pUh0kHdKQe0s_7micbcv3JKiBlxsYGs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5edn6k&ms=au%2Conr&mt=1659459429&mv=m&mvi=4&n=T16m7p0RvV7UhQ&ns=tWuNfisHu8yiCA6Avm7nUlwH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAKP_KjT_SSnz5WGXaveO56pJAEw166qT3cpBdAZI1zwCAiBWZKVQZxfOPWnqSp5FnRDyQBQ-6a2nZopyA1eHicgHtw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-KhrZGE2opztWyVdAtyUNlb8dXPDs&txp=1311222&vprv=1" |  | ||||||
|     itag: 251 |  | ||||||
|     bitrate: 140633 |  | ||||||
|     average_bitrate: 121491 |  | ||||||
|     size: 2476314 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
| subtitles: |  | ||||||
|   - url: "https://www.youtube.com/api/timedtext?v=pPvd8UxmSbQ&caps=asr&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1659484955&sparams=ip,ipbits,expire,v,caps,xoaf&signature=AEE666C3CB25B28A0990FAF6483CAEFCC3F3BCEA.BBAD928634677A2CF95FB67B14F362750449F8F5&key=yt8&lang=en" |  | ||||||
|     lang: en |  | ||||||
|     lang_name: English |  | ||||||
|     auto_generated: false |  | ||||||
| expires_in_seconds: 21540 |  | ||||||
| 
 |  | ||||||
|  | @ -1,319 +0,0 @@ | ||||||
| --- |  | ||||||
| source: src/client/player.rs |  | ||||||
| expression: player_data |  | ||||||
| --- |  | ||||||
| info: |  | ||||||
|   id: pPvd8UxmSbQ |  | ||||||
|   title: Inspiring Cinematic Uplifting |  | ||||||
|   description: ~ |  | ||||||
|   length: 163 |  | ||||||
|   thumbnails: |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AOn4CLC-0nIQMyPuy8CtzqTMl6z1rmG_XQ" |  | ||||||
|       width: 400 |  | ||||||
|       height: 225 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AOn4CLD5woRwkLSroWdO5l5XPprkZc7sGQ" |  | ||||||
|       width: 800 |  | ||||||
|       height: 450 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AOn4CLA1wPf8NLXqHitgwoNBp4ydFCtDiA" |  | ||||||
|       width: 853 |  | ||||||
|       height: 480 |  | ||||||
|   channel: |  | ||||||
|     id: UCbxxEi-ImPlbLx5F-fHetEg |  | ||||||
|     name: Romansenykmusic |  | ||||||
|   publish_date: "2019-05-30T00:00:00" |  | ||||||
|   view_count: 426583 |  | ||||||
|   keywords: |  | ||||||
|     - no copyright music |  | ||||||
|     - background music |  | ||||||
|     - copyright free music |  | ||||||
|     - non copyrighted music |  | ||||||
|     - free music |  | ||||||
|     - no copyright music cinematic |  | ||||||
|     - inspiring music |  | ||||||
|     - inspiring background music |  | ||||||
|     - cinematic music |  | ||||||
|     - cinematic background music |  | ||||||
|     - no copyright music inspiring |  | ||||||
|     - free music no copyright |  | ||||||
|     - uplifting music |  | ||||||
|     - trailer music no copyright |  | ||||||
|     - trailer music |  | ||||||
|     - download music |  | ||||||
|     - free background music |  | ||||||
|     - orchestral music |  | ||||||
|     - romansenykmusic |  | ||||||
|     - motivational music |  | ||||||
|     - montage music |  | ||||||
|   category: Music |  | ||||||
|   is_live_content: false |  | ||||||
|   is_family_safe: true |  | ||||||
| video_streams: |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=11439331&dur=163.096&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=18&lmt=1580005476071743&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=mAEwZepBJSQPkQ&ns=orl5qWACo00YlHHyQZ7a6awH&pl=37&ratebypass=yes&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhANfXubbDcXpc25m3F5xQ97ygJRjrTvm8ruVxgnxgFAUBAiAEnj_3KacDNTTLUk-6ZEbL-52zxmBLU1iuTEDx0NvJzA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=2211222&vprv=1" |  | ||||||
|     itag: 18 |  | ||||||
|     bitrate: 561339 |  | ||||||
|     average_bitrate: 561109 |  | ||||||
|     size: 11439331 |  | ||||||
|     index_range: ~ |  | ||||||
|     init_range: ~ |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
| video_only_streams: |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=1484736&dur=163.029&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=278&keepalive=yes&lmt=1608509388295661&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgEQ0-VVvo41T4l2X26p5zP8Wo8sXOPmBWvCf2OW33ilgCIH2bIFOYgpmsml7FvRQj_SoLzPh7yBvmLZ61Kgsj4FUe&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 278 |  | ||||||
|     bitrate: 87458 |  | ||||||
|     average_bitrate: 72857 |  | ||||||
|     size: 1484736 |  | ||||||
|     index_range: |  | ||||||
|       start: 218 |  | ||||||
|       end: 751 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 217 |  | ||||||
|     width: 256 |  | ||||||
|     height: 144 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 144p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=2973283&dur=163.029&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=242&keepalive=yes&lmt=1608509388282028&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAO7DI5E91yHpLhgiWg9C99NsMoJBVOWsNTNF3os9kREQAiAr2oC8vFtXIHwkJJt45q0sdmjiJdkTO2i8VAjUodk6Xw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 242 |  | ||||||
|     bitrate: 184064 |  | ||||||
|     average_bitrate: 145902 |  | ||||||
|     size: 2973283 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 753 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 426 |  | ||||||
|     height: 240 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 240p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=7808990&dur=163.029&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=134&keepalive=yes&lmt=1580005649163759&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgTkOjFd0nExEtpr8sBIaNu9HhkxWNdjhSKufHMhLR8-8CIHJAmOuCD7VBv_krH6rn5zqXFqAfsq9rQPXlC3CcQrjM&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=2211222&vprv=1" |  | ||||||
|     itag: 134 |  | ||||||
|     bitrate: 538143 |  | ||||||
|     average_bitrate: 383195 |  | ||||||
|     size: 7808990 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.4d401e\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=5169510&dur=163.029&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=243&keepalive=yes&lmt=1608509388282405&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAPqQfxwIANgIC3DrQ6avaWOhCvIMLdzMPQtFOx2gwEXNAiAwJp2mgN9-zl4vPOB2uoQXOfmGsYDB470q1zg7wRW4Sw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 243 |  | ||||||
|     bitrate: 319085 |  | ||||||
|     average_bitrate: 253673 |  | ||||||
|     size: 5169510 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=8890590&dur=163.029&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=244&keepalive=yes&lmt=1608509388284632&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAIjdvhcThMxoo_v2bzEjaR_w0ryWFQDs0f0INaI5WPcVAiApQZUYTqcQJdfxZlNSsp7cl3FK8XPfDZ-qbVvj9GuauQ%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 244 |  | ||||||
|     bitrate: 539056 |  | ||||||
|     average_bitrate: 436270 |  | ||||||
|     size: 8890590 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 854 |  | ||||||
|     height: 480 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 480p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=16547577&dur=163.029&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=247&keepalive=yes&lmt=1608509388326822&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgBV4Oa1IQ0YNDvRrKO5ec3Pfbg65MxzmIxCcm0gOuwT0CIFysQdow6DQXzz1W9KZVuqACTdjXQ3-yiBj9GcmNw3HE&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 247 |  | ||||||
|     bitrate: 982813 |  | ||||||
|     average_bitrate: 812006 |  | ||||||
|     size: 16547577 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 720p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=35955780&dur=163.046&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=302&keepalive=yes&lmt=1608509234088626&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAOiqSNfGfOprZ9InWVMc7gY0KrTf8weLibcpK0W2Hfa6AiAFHW213qsByzlar5ivCAYttjo1rPciQnLEnh-izJ3ZhA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 302 |  | ||||||
|     bitrate: 2354009 |  | ||||||
|     average_bitrate: 1764202 |  | ||||||
|     size: 35955780 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 771 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 720p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=65400181&dur=163.046&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=299&keepalive=yes&lmt=1580005649161486&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgdkJv6w9_Azf0m6poA-ULyX0eH_GKBtSJRwUY1lNBAZgCIDCrC0lnu__ycTaIhg0pUcsRUqay60S3QMo5084EWifd&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=2211222&vprv=1" |  | ||||||
|     itag: 299 |  | ||||||
|     bitrate: 4190323 |  | ||||||
|     average_bitrate: 3208919 |  | ||||||
|     size: 65400181 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.64002a\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=62993617&dur=163.046&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=303&keepalive=yes&lmt=1608509371758331&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgZi9dDSMWh10NID8-QNn3azIH1zw5UooZrRTPZjVn7hYCIAm9bFc6NBwJ_DzY4V2R_zGmJSpOwQl8LEsfCb7hf6i7&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 303 |  | ||||||
|     bitrate: 3832648 |  | ||||||
|     average_bitrate: 3090839 |  | ||||||
|     size: 62993617 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 776 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
| audio_streams: |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=934449&dur=163.061&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=249&keepalive=yes&lmt=1608509101590706&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAMUfr2X-eQJt1abn-IK1H4d5DtvKZuBaETo4opNi6mqCAiEAvBmrmuaoFjB1CJ2Kug87w-Uv6OCdxyrJ_3HIaHX9KBI%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 249 |  | ||||||
|     bitrate: 53039 |  | ||||||
|     average_bitrate: 45845 |  | ||||||
|     size: 934449 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=1245866&dur=163.061&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=250&keepalive=yes&lmt=1608509101111096&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAIflUU4t4Ukf4CXW3ttB5c8SnP4z4z3ef-7EFVMFv4U8AiAlcKmmofCTzzr2NRRsRVosQdpDBphUqWyVzS7noGrixw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 250 |  | ||||||
|     bitrate: 71268 |  | ||||||
|     average_bitrate: 61123 |  | ||||||
|     size: 1245866 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=2640283&dur=163.096&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=140&keepalive=yes&lmt=1580005579712232&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhALBveArAZ7DP9r1BIpNz6ZXst5MtzvUM72jhtYMrildCAiEArvwqaqcowZwR_UrRM-O7jq2CMxgBtbnmul27AEcBqEI%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=2211222&vprv=1" |  | ||||||
|     itag: 140 |  | ||||||
|     bitrate: 130268 |  | ||||||
|     average_bitrate: 129508 |  | ||||||
|     size: 2640283 |  | ||||||
|     index_range: |  | ||||||
|       start: 632 |  | ||||||
|       end: 867 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 631 |  | ||||||
|     mime: "audio/mp4; codecs=\"mp4a.40.2\"" |  | ||||||
|     format: m4a |  | ||||||
|     codec: mp4a |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=WEB_REMIX&clen=2476314&dur=163.061&ei=knDpYub6BojEgAf6jbLgDw&expire=1659487474&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AM-wcJVO-yYYbVFnuifnzM4eRnD-AG1bS1AhLoDqi_is&initcwndbps=1418750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=251&keepalive=yes&lmt=1608509101894140&lsig=AG3C_xAwRgIhAMwYJqxve8BSujC-oaSFBbq67p-rFi7saU5V8Yb3qrjLAiEAlrMKR_sadHrkFpy7o7lGzKOCmU1OQazCNBbXjDT2a-o%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659465669&mv=m&mvi=5&n=1taQMNHGExb_Vg&ns=UTT8RXHZNhPYTw6NgkzWMWEH&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgARPqD-6172OshMHeV8DpONV7tnPvdsxcg8QlaIGxcuMCICSe8LWvhRTEO2bdAQ43OzOoc5XfJcj3veyhScVXVz8O&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&spc=lT-Khox4YuJQ2wmH79zYALRvsWTPCUc&txp=1311222&vprv=1" |  | ||||||
|     itag: 251 |  | ||||||
|     bitrate: 140633 |  | ||||||
|     average_bitrate: 121491 |  | ||||||
|     size: 2476314 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
| subtitles: |  | ||||||
|   - url: "https://www.youtube.com/api/timedtext?v=pPvd8UxmSbQ&caps=asr&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1659491074&sparams=ip,ipbits,expire,v,caps,xoaf&signature=3CE2AD03E73FC43D83E992060A96D7B12F8E08E5.66CE90B1F3337004848429E1091E8423497239BF&key=yt8&lang=en" |  | ||||||
|     lang: en |  | ||||||
|     lang_name: English |  | ||||||
|     auto_generated: false |  | ||||||
| expires_in_seconds: 21540 |  | ||||||
| 
 |  | ||||||
|  | @ -1,131 +0,0 @@ | ||||||
| --- |  | ||||||
| source: src/client/player.rs |  | ||||||
| expression: player_data |  | ||||||
| --- |  | ||||||
| info: |  | ||||||
|   id: pPvd8UxmSbQ |  | ||||||
|   title: Inspiring Cinematic Uplifting (Creative Commons) |  | ||||||
|   description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk" |  | ||||||
|   length: 163 |  | ||||||
|   thumbnails: |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/mqdefault.jpg" |  | ||||||
|       width: 320 |  | ||||||
|       height: 180 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hqdefault.jpg" |  | ||||||
|       width: 480 |  | ||||||
|       height: 360 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/sddefault.jpg" |  | ||||||
|       width: 640 |  | ||||||
|       height: 480 |  | ||||||
|   channel: |  | ||||||
|     id: UCbxxEi-ImPlbLx5F-fHetEg |  | ||||||
|     name: RomanSenykMusic - Royalty Free Music |  | ||||||
|   publish_date: "~" |  | ||||||
|   view_count: 426567 |  | ||||||
|   keywords: |  | ||||||
|     - no copyright music |  | ||||||
|     - background music |  | ||||||
|     - copyright free music |  | ||||||
|     - non copyrighted music |  | ||||||
|     - free music |  | ||||||
|     - no copyright music cinematic |  | ||||||
|     - inspiring music |  | ||||||
|     - inspiring background music |  | ||||||
|     - cinematic music |  | ||||||
|     - cinematic background music |  | ||||||
|     - no copyright music inspiring |  | ||||||
|     - free music no copyright |  | ||||||
|     - uplifting music |  | ||||||
|     - trailer music no copyright |  | ||||||
|     - trailer music |  | ||||||
|     - download music |  | ||||||
|     - free background music |  | ||||||
|     - orchestral music |  | ||||||
|     - romansenykmusic |  | ||||||
|     - motivational music |  | ||||||
|     - montage music |  | ||||||
|   category: ~ |  | ||||||
|   is_live_content: false |  | ||||||
|   is_family_safe: ~ |  | ||||||
| video_streams: [] |  | ||||||
| video_only_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=IOS&clen=7808990&dur=163.029&ei=q1jpYq-xHs7NgQev0bfwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-ANNg3iPHI56jhLSlPQk4pi4mdub5iAby0hmJBVrtiJgY&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=134&keepalive=yes&lmt=1580005649163759&lsig=AG3C_xAwRQIgWKVoDpyI6QmVnkdGzdirFtjMAXhmLex64VTO7UUJd-4CIQDoJKkT2-Kpa7j0merJJoZDs4IkkXSjdNm3bvdCL8t2Pg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAMFQo_RyC3Ud44QVGtKckMcuq5UQ3J7CgYsYl0bXaWMUAiEAhMi1h0ru4zoIGX0jBZT23dozvtrhf_m61p4qbAfEhZo%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&svpuc=1&txp=2211222&vprv=1" |  | ||||||
|     itag: 134 |  | ||||||
|     bitrate: 538143 |  | ||||||
|     average_bitrate: 383195 |  | ||||||
|     size: 7808990 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.4D401E\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=IOS&clen=65400181&dur=163.046&ei=q1jpYq-xHs7NgQev0bfwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-ANNg3iPHI56jhLSlPQk4pi4mdub5iAby0hmJBVrtiJgY&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=299&keepalive=yes&lmt=1580005649161486&lsig=AG3C_xAwRQIgWKVoDpyI6QmVnkdGzdirFtjMAXhmLex64VTO7UUJd-4CIQDoJKkT2-Kpa7j0merJJoZDs4IkkXSjdNm3bvdCL8t2Pg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAP6zxXXA18ToZWUfalauhhsgOsDHTu-R0QrqNrJR7D5kAiEAi8HBa9OkYwmA0bcRxhgvXfN9JsFlXwCWJ-x4ty6TjoY%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&svpuc=1&txp=2211222&vprv=1" |  | ||||||
|     itag: 299 |  | ||||||
|     bitrate: 4190323 |  | ||||||
|     average_bitrate: 3208919 |  | ||||||
|     size: 65400181 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.64002A\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
| audio_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=IOS&clen=995840&dur=163.189&ei=q1jpYq-xHs7NgQev0bfwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-ANNg3iPHI56jhLSlPQk4pi4mdub5iAby0hmJBVrtiJgY&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=139&keepalive=yes&lmt=1580005582214385&lsig=AG3C_xAwRQIgWKVoDpyI6QmVnkdGzdirFtjMAXhmLex64VTO7UUJd-4CIQDoJKkT2-Kpa7j0merJJoZDs4IkkXSjdNm3bvdCL8t2Pg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAMXDvZZznm1LafIKh_pdGf-TjY5Kz-F9N67o6gXenKouAiEA5qji45i5ABmAytPDOORjw0OaBmwX88S7bgUWcmF-_LU%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&svpuc=1&txp=2211222&vprv=1" |  | ||||||
|     itag: 139 |  | ||||||
|     bitrate: 49724 |  | ||||||
|     average_bitrate: 48818 |  | ||||||
|     size: 995840 |  | ||||||
|     index_range: |  | ||||||
|       start: 641 |  | ||||||
|       end: 876 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 640 |  | ||||||
|     mime: "audio/mp4; codecs=\"mp4a.40.5\"" |  | ||||||
|     format: m4a |  | ||||||
|     codec: mp4a |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=IOS&clen=2640283&dur=163.096&ei=q1jpYq-xHs7NgQev0bfwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-ANNg3iPHI56jhLSlPQk4pi4mdub5iAby0hmJBVrtiJgY&initcwndbps=1513750&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=140&keepalive=yes&lmt=1580005579712232&lsig=AG3C_xAwRQIgWKVoDpyI6QmVnkdGzdirFtjMAXhmLex64VTO7UUJd-4CIQDoJKkT2-Kpa7j0merJJoZDs4IkkXSjdNm3bvdCL8t2Pg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgcsPm6rrUAwCi1VTGf0FMDTjzjM01__iTC13PnzDTFeQCIQCJ_EGeKVZztkmK3Cr7gVuxUP83XCSlP01YLx5FO-PPcQ%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&svpuc=1&txp=2211222&vprv=1" |  | ||||||
|     itag: 140 |  | ||||||
|     bitrate: 130268 |  | ||||||
|     average_bitrate: 129508 |  | ||||||
|     size: 2640283 |  | ||||||
|     index_range: |  | ||||||
|       start: 632 |  | ||||||
|       end: 867 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 631 |  | ||||||
|     mime: "audio/mp4; codecs=\"mp4a.40.2\"" |  | ||||||
|     format: m4a |  | ||||||
|     codec: mp4a |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
| subtitles: |  | ||||||
|   - url: "https://www.youtube.com/api/timedtext?v=pPvd8UxmSbQ&caps=asr&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1659484955&sparams=ip,ipbits,expire,v,caps,xoaf&signature=A9D9281218AFF99DC34B5AA2F44D33455F419673.44DBC65B8479A5F088D6136FC4364894EECB3E0E&key=yt8&lang=en" |  | ||||||
|     lang: en |  | ||||||
|     lang_name: English |  | ||||||
|     auto_generated: false |  | ||||||
| expires_in_seconds: 21540 |  | ||||||
| 
 |  | ||||||
|  | @ -1,445 +0,0 @@ | ||||||
| --- |  | ||||||
| source: src/client/player.rs |  | ||||||
| expression: player_data |  | ||||||
| --- |  | ||||||
| info: |  | ||||||
|   id: pPvd8UxmSbQ |  | ||||||
|   title: Inspiring Cinematic Uplifting (Creative Commons) |  | ||||||
|   description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk" |  | ||||||
|   length: 163 |  | ||||||
|   thumbnails: |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/default.jpg" |  | ||||||
|       width: 120 |  | ||||||
|       height: 90 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/mqdefault.jpg" |  | ||||||
|       width: 320 |  | ||||||
|       height: 180 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hqdefault.jpg" |  | ||||||
|       width: 480 |  | ||||||
|       height: 360 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/sddefault.jpg" |  | ||||||
|       width: 640 |  | ||||||
|       height: 480 |  | ||||||
|     - url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/maxresdefault.jpg" |  | ||||||
|       width: 1920 |  | ||||||
|       height: 1080 |  | ||||||
|   channel: |  | ||||||
|     id: UCbxxEi-ImPlbLx5F-fHetEg |  | ||||||
|     name: RomanSenykMusic - Royalty Free Music |  | ||||||
|   publish_date: "~" |  | ||||||
|   view_count: 426567 |  | ||||||
|   keywords: |  | ||||||
|     - no copyright music |  | ||||||
|     - background music |  | ||||||
|     - copyright free music |  | ||||||
|     - non copyrighted music |  | ||||||
|     - free music |  | ||||||
|     - no copyright music cinematic |  | ||||||
|     - inspiring music |  | ||||||
|     - inspiring background music |  | ||||||
|     - cinematic music |  | ||||||
|     - cinematic background music |  | ||||||
|     - no copyright music inspiring |  | ||||||
|     - free music no copyright |  | ||||||
|     - uplifting music |  | ||||||
|     - trailer music no copyright |  | ||||||
|     - trailer music |  | ||||||
|     - download music |  | ||||||
|     - free background music |  | ||||||
|     - orchestral music |  | ||||||
|     - romansenykmusic |  | ||||||
|     - motivational music |  | ||||||
|     - montage music |  | ||||||
|   category: ~ |  | ||||||
|   is_live_content: false |  | ||||||
|   is_family_safe: ~ |  | ||||||
| video_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=11439331&dur=163.096&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=18&lmt=1580005476071743&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=nyEBf7PFRQzT2Q&ns=K7tV6wZuXWbiwSsCxjud4dwH&pcm2=yes&pl=37&ratebypass=yes&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgA7RsBrMSenhLIt0g53KA8JKsUoMfcUilAk8IWpN7Rw0CIQDrRhd7fw58T5vBIqMJk8kXvvHbVODatGsSMbl6CL_s6A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 18 |  | ||||||
|     bitrate: 561339 |  | ||||||
|     average_bitrate: 561109 |  | ||||||
|     size: 11439331 |  | ||||||
|     index_range: ~ |  | ||||||
|     init_range: ~ |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
| video_only_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=1484736&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=278&keepalive=yes&lmt=1608509388295661&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgUfAx1SnRXZMjOZ9y8K-PFD5_vaoqQOhCT-fgK6iX8W4CIQDaAZnRtrvXNNQYLzh87R9UUbL6swXSECd0onuWwxpOjA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 278 |  | ||||||
|     bitrate: 87458 |  | ||||||
|     average_bitrate: 72857 |  | ||||||
|     size: 1484736 |  | ||||||
|     index_range: |  | ||||||
|       start: 218 |  | ||||||
|       end: 751 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 217 |  | ||||||
|     width: 256 |  | ||||||
|     height: 144 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 144p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=1224002&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=394&keepalive=yes&lmt=1608045375671513&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAKyA5SE5VppKcNlosTsDsa4s039Ia-Qymp9zS3hAlScmAiBzo8tirHhDQVcMHejguHQ3F5rglFmjjy1hFlopVpNe-A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 394 |  | ||||||
|     bitrate: 67637 |  | ||||||
|     average_bitrate: 60063 |  | ||||||
|     size: 1224002 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 256 |  | ||||||
|     height: 144 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 144p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.00M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=2973283&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=242&keepalive=yes&lmt=1608509388282028&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgN7FPp-_Ay_e78kvW7bcBceUhHDnpgXSZKxxn-x34DTgCIEqr4KN5E3R9ZVzCFV3HGaTr6YZEGeNDRxS4ne7JFDRN&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 242 |  | ||||||
|     bitrate: 184064 |  | ||||||
|     average_bitrate: 145902 |  | ||||||
|     size: 2973283 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 753 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 426 |  | ||||||
|     height: 240 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 240p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=2238952&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=395&keepalive=yes&lmt=1608045728968690&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAKBPl7ZiI0t6SteLZUEX96zhu1FVKBLZz6GP-_6K-nJMAiBcWq7zKq-fNeSJbMaGcrgU8tshLKzNu2Mv0b1pFrPbMw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 395 |  | ||||||
|     bitrate: 135747 |  | ||||||
|     average_bitrate: 109867 |  | ||||||
|     size: 2238952 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 426 |  | ||||||
|     height: 240 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 240p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.00M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=7808990&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=134&keepalive=yes&lmt=1580005649163759&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgLnuMRsG-Huz0E9KzrpsLbN8akn6slETHnYESZLtoJXgCIFXPrk4JyA2KRZnD8EVn7c1JRqFNUV1acExNy0Z6wfeX&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 134 |  | ||||||
|     bitrate: 538143 |  | ||||||
|     average_bitrate: 383195 |  | ||||||
|     size: 7808990 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.4d401e\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=5169510&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=243&keepalive=yes&lmt=1608509388282405&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhANJoH9RPIFwd08jukBbSBYSH-gmli5NIdZRVDZD8StFiAiEAtjCXNscOn1rgndc2QQQYV97sWCCYPwWvO0tgkUjRm74%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 243 |  | ||||||
|     bitrate: 319085 |  | ||||||
|     average_bitrate: 253673 |  | ||||||
|     size: 5169510 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=4130385&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=396&keepalive=yes&lmt=1608045761576250&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgcVEF2GELVbjio4lbmnBkFmi2HT4gkRQyM-SU3Tv-bMgCIQDs8WhxxNLSj3K-0ccvv6wzpWweOuwhdj9hjCXa0-9PnQ%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 396 |  | ||||||
|     bitrate: 258097 |  | ||||||
|     average_bitrate: 202682 |  | ||||||
|     size: 4130385 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 640 |  | ||||||
|     height: 360 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 360p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.01M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=8890590&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=244&keepalive=yes&lmt=1608509388284632&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgEC-9_1jHyfgc_Vtpe7vuWTJYd2S_MrJaSDfYfx8cCQcCIEIPWqkLyLh3yLlAM-ZPpySBXCS9Z9Hs1Mk_dVLsnBhY&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 244 |  | ||||||
|     bitrate: 539056 |  | ||||||
|     average_bitrate: 436270 |  | ||||||
|     size: 8890590 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 854 |  | ||||||
|     height: 480 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 480p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=6873325&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=397&keepalive=yes&lmt=1608045990917419&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAK8Grn-QuhjptRGaHT2NYU97O15VoIXwX0EYKhl4FIFIAiEA9152IGHn7QbRCGRfk1Q0Yqfpr9Hhjp-u4e8L8vhuXtk%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 397 |  | ||||||
|     bitrate: 436843 |  | ||||||
|     average_bitrate: 337281 |  | ||||||
|     size: 6873325 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 854 |  | ||||||
|     height: 480 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 480p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.04M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=16547577&dur=163.029&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=247&keepalive=yes&lmt=1608509388326822&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgFVGnmP4_M__D1Lga0s1av1aEBTmW54m9NdJY5I88xaECIQDMMIOCWFm-Aje4sHxWihE_tFpg1qrfS0qlbGRtouR1zA%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 247 |  | ||||||
|     bitrate: 982813 |  | ||||||
|     average_bitrate: 812006 |  | ||||||
|     size: 16547577 |  | ||||||
|     index_range: |  | ||||||
|       start: 220 |  | ||||||
|       end: 754 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 219 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 30 |  | ||||||
|     quality: 720p |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=35955780&dur=163.046&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=302&keepalive=yes&lmt=1608509234088626&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAKDysUcBDLlWx0vZ8CifiOcjQWBo4uc9JlogYR4z1cX0AiEA6Jgek2vwU6z3zM-aiQDh7GZXX2f19HPPKxwhZLvkshE%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 302 |  | ||||||
|     bitrate: 2354009 |  | ||||||
|     average_bitrate: 1764202 |  | ||||||
|     size: 35955780 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 771 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 720p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=22365208&dur=163.046&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=398&keepalive=yes&lmt=1608048380553749&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgcHUn_ogkBtSQLpq8m-l4IqLlx7EKsddusFPuwvMlLuoCIDF1FiMdigJzd_H5xIgglkW7GaS3CG5Sx9aC2O5pAtUG&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 398 |  | ||||||
|     bitrate: 1348419 |  | ||||||
|     average_bitrate: 1097369 |  | ||||||
|     size: 22365208 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 1280 |  | ||||||
|     height: 720 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 720p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.08M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=65400181&dur=163.046&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=299&keepalive=yes&lmt=1580005649161486&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgRoFTJHusyDU4PA4tIpFb7cNHxwiKOH_C5FGDdcx16ScCIC2SlCLt3gTJ2mUuTbav41TnZ5pVEAbiLxuY6pMV4stE&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 299 |  | ||||||
|     bitrate: 4190323 |  | ||||||
|     average_bitrate: 3208919 |  | ||||||
|     size: 65400181 |  | ||||||
|     index_range: |  | ||||||
|       start: 740 |  | ||||||
|       end: 1155 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 739 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"avc1.64002a\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: avc1 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=62993617&dur=163.046&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=303&keepalive=yes&lmt=1608509371758331&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgIChm15WPOCXfBDCY0W_4Ul3wdL8YRia4knFoPl_u8AsCIQCTSOnu_bi5-FkCPiOM0P8WTDaXo9hGJuYmxguzxbF88A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 303 |  | ||||||
|     bitrate: 3832648 |  | ||||||
|     average_bitrate: 3090839 |  | ||||||
|     size: 62993617 |  | ||||||
|     index_range: |  | ||||||
|       start: 219 |  | ||||||
|       end: 776 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 218 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/webm; codecs=\"vp9\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: vp9 |  | ||||||
|     throttled: false |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C394%2C395%2C396%2C397%2C398%2C399&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=42567727&dur=163.046&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=399&keepalive=yes&lmt=1608052932785283&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgO3omBCES-iEOIeuiy9Jsz9wB_QfRkCuRCiCQ-N5KdqoCIQDANFWf0zfBSm1qGjA7jYJEti7hiM9klZHFZjC2CN9r9A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 399 |  | ||||||
|     bitrate: 2572342 |  | ||||||
|     average_bitrate: 2088624 |  | ||||||
|     size: 42567727 |  | ||||||
|     index_range: |  | ||||||
|       start: 700 |  | ||||||
|       end: 1115 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 699 |  | ||||||
|     width: 1920 |  | ||||||
|     height: 1080 |  | ||||||
|     fps: 60 |  | ||||||
|     quality: 1080p60 |  | ||||||
|     hdr: false |  | ||||||
|     mime: "video/mp4; codecs=\"av01.0.09M.08\"" |  | ||||||
|     format: mp4 |  | ||||||
|     codec: av01 |  | ||||||
|     throttled: false |  | ||||||
| audio_streams: |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=934449&dur=163.061&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=249&keepalive=yes&lmt=1608509101590706&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAKvEZsn0-D0htBi8J0Eu97RHLDnsiv4QrcG6-IGxnvrJAiEA90KJc7FhPTsFG5h_nXnDgyAeH1XrN6K-DjpIClZFSLw%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 249 |  | ||||||
|     bitrate: 53039 |  | ||||||
|     average_bitrate: 45845 |  | ||||||
|     size: 934449 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=1245866&dur=163.061&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=250&keepalive=yes&lmt=1608509101111096&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIgdXM4noOweo7ObtzNi89kUq6sJ3zVxwtqYRSGoTG5nx8CIQDYchUdbpIodrPu6p1LDPr-fMogyV5qHLhKG68u3hXoLw%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 250 |  | ||||||
|     bitrate: 71268 |  | ||||||
|     average_bitrate: 61123 |  | ||||||
|     size: 1245866 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=2640283&dur=163.096&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=140&keepalive=yes&lmt=1580005579712232&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fmp4&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRgIhAMYwUTZ3JEkIJFY-hwYPGvGZw3M0IzvFHenqSGb6ksrkAiEA6kBUIvpILbdtJFpB6KHjbLG2nGdAS0MaodDvaEC5nwU%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=2211222&vprv=1" |  | ||||||
|     itag: 140 |  | ||||||
|     bitrate: 130268 |  | ||||||
|     average_bitrate: 129508 |  | ||||||
|     size: 2640283 |  | ||||||
|     index_range: |  | ||||||
|       start: 632 |  | ||||||
|       end: 867 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 631 |  | ||||||
|     mime: "audio/mp4; codecs=\"mp4a.40.2\"" |  | ||||||
|     format: m4a |  | ||||||
|     codec: mp4a |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
|   - url: "https://rr4---sn-h0jelnez.googlevideo.com/videoplayback?c=TVHTML5_SIMPLY_EMBEDDED_PLAYER&clen=2476314&dur=163.061&ei=q1jpYv-eJ9uF6dsPhvyH8As&expire=1659481355&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AKkOKYSoYWWfNLdrt3aQbxbIwHh4mMVyXLb2mtjc0uXQ&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=251&keepalive=yes&lmt=1608509101894140&lsig=AG3C_xAwRgIhAIWRo8U-MB6jhlipPec3A3m5-StMaX64EEGBEE3LWaNiAiEA_8QPrTStO0ISMA5Jex-G2JfPpqyw-vltC8nAFXyPz98%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=audio%2Fwebm&mm=31%2C29&mn=sn-h0jelnez%2Csn-h0jeenek&ms=au%2Crdu&mt=1659459429&mv=m&mvi=4&n=U0g9MK69PQnuYQ&ns=h4nQ-PcvhL4hLZwTU9i7QAoH&otfp=1&pcm2=yes&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgFTu4utWltpNE2oh6bB_y36RqUxl-B47UPoShqGF56QoCIHb4_DD2-sLJv9x_NPp1j_NyU4J6cqShl3rUJNuigwuh&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cpcm2%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1" |  | ||||||
|     itag: 251 |  | ||||||
|     bitrate: 140633 |  | ||||||
|     average_bitrate: 121491 |  | ||||||
|     size: 2476314 |  | ||||||
|     index_range: |  | ||||||
|       start: 266 |  | ||||||
|       end: 551 |  | ||||||
|     init_range: |  | ||||||
|       start: 0 |  | ||||||
|       end: 265 |  | ||||||
|     mime: "audio/webm; codecs=\"opus\"" |  | ||||||
|     format: webm |  | ||||||
|     codec: opus |  | ||||||
|     throttled: false |  | ||||||
|     track: ~ |  | ||||||
| subtitles: |  | ||||||
|   - url: "https://www.youtube.com/api/timedtext?v=pPvd8UxmSbQ&caps=asr&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1659484955&sparams=ip,ipbits,expire,v,caps,xoaf&signature=DAB4A9E85FEBC1ED6E527CD113FB58F798859727.32748FCE40F82BE8A271BF5FF248C2B283B57F89&key=yt8&lang=en" |  | ||||||
|     lang: en |  | ||||||
|     lang_name: English |  | ||||||
|     auto_generated: false |  | ||||||
| expires_in_seconds: 21540 |  | ||||||
| 
 |  | ||||||
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -3,19 +3,25 @@ use fancy_regex::Regex; | ||||||
| use log::debug; | use log::debug; | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use reqwest::Client; | use reqwest::Client; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
| use std::result::Result::Ok; | use std::result::Result::Ok; | ||||||
| 
 | 
 | ||||||
| use crate::cache::{Cache, DeobfData}; |  | ||||||
| use crate::util; | use crate::util; | ||||||
| 
 | 
 | ||||||
| pub struct Deobfuscator { | pub struct Deobfuscator { | ||||||
|     data: DeobfData, |     data: DeobfData, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] | ||||||
|  | pub struct DeobfData { | ||||||
|  |     pub js_url: String, | ||||||
|  |     pub sig_fn: String, | ||||||
|  |     pub nsig_fn: String, | ||||||
|  |     pub sts: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
| impl Deobfuscator { | impl Deobfuscator { | ||||||
|     pub async fn from_fetched_info(http: Client, cache: Cache) -> Result<Self> { |     pub async fn new(http: Client) -> Result<Self> { | ||||||
|         let data = cache |  | ||||||
|             .get_deobf_data(async move { |  | ||||||
|         let js_url = get_player_js_url(&http) |         let js_url = get_player_js_url(&http) | ||||||
|             .await |             .await | ||||||
|             .context("Failed to retrieve player.js URL")?; |             .context("Failed to retrieve player.js URL")?; | ||||||
|  | @ -30,16 +36,14 @@ impl Deobfuscator { | ||||||
|         let nsig_fn = get_nsig_fn(&player_js)?; |         let nsig_fn = get_nsig_fn(&player_js)?; | ||||||
|         let sts = get_sts(&player_js)?; |         let sts = get_sts(&player_js)?; | ||||||
| 
 | 
 | ||||||
|                 Ok(DeobfData { |         Ok(Self { | ||||||
|  |             data: DeobfData { | ||||||
|                 js_url, |                 js_url, | ||||||
|                 nsig_fn, |                 nsig_fn, | ||||||
|                 sig_fn, |                 sig_fn, | ||||||
|                 sts, |                 sts, | ||||||
|  |             }, | ||||||
|         }) |         }) | ||||||
|             }) |  | ||||||
|             .await?; |  | ||||||
| 
 |  | ||||||
|         Ok(Self { data }) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn deobfuscate_sig(&self, sig: &str) -> Result<String> { |     pub fn deobfuscate_sig(&self, sig: &str) -> Result<String> { | ||||||
|  | @ -53,6 +57,10 @@ impl Deobfuscator { | ||||||
|     pub fn get_sts(&self) -> String { |     pub fn get_sts(&self) -> String { | ||||||
|         self.data.sts.to_owned() |         self.data.sts.to_owned() | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     pub fn get_data(&self) -> DeobfData { | ||||||
|  |         self.data.to_owned() | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl From<DeobfData> for Deobfuscator { | impl From<DeobfData> for Deobfuscator { | ||||||
|  | @ -80,7 +88,7 @@ fn get_sig_fn_name(player_js: &str) -> Result<String> { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn caller_function(fn_name: &str) -> String { | fn caller_function(fn_name: &str) -> String { | ||||||
|     "var ".to_owned() + DEOBFUSCATION_FUNC_NAME + "=" + &fn_name + ";" |     format!("var {}={};", DEOBFUSCATION_FUNC_NAME, fn_name) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn get_sig_fn(player_js: &str) -> Result<String> { | fn get_sig_fn(player_js: &str) -> Result<String> { | ||||||
|  | @ -345,7 +353,7 @@ fn get_sts(player_js: &str) -> Result<String> { | ||||||
|         Lazy::new(|| Regex::new("signatureTimestamp[=:](\\d+)").unwrap()); |         Lazy::new(|| Regex::new("signatureTimestamp[=:](\\d+)").unwrap()); | ||||||
| 
 | 
 | ||||||
|     Ok(some_or_bail!( |     Ok(some_or_bail!( | ||||||
|         STS_PATTERN.captures(&player_js)?, |         STS_PATTERN.captures(player_js)?, | ||||||
|         Err(anyhow!("could not find sts")) |         Err(anyhow!("could not find sts")) | ||||||
|     ) |     ) | ||||||
|     .get(1) |     .get(1) | ||||||
|  | @ -472,10 +480,7 @@ c[36](c[8],c[32]),c[20](c[25],c[10]),c[2](c[22],c[8]),c[32](c[20],c[16]),c[32](c | ||||||
|     #[test(tokio::test)] |     #[test(tokio::test)] | ||||||
|     async fn t_update() { |     async fn t_update() { | ||||||
|         let client = Client::new(); |         let client = Client::new(); | ||||||
|         let cache = Cache::default(); |         let deobf = Deobfuscator::new(client).await.unwrap(); | ||||||
|         let deobf = Deobfuscator::from_fetched_info(client, cache) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 | 
 | ||||||
|         let deobf_sig = deobf.deobfuscate_sig("GOqGOqGOq0QJ8wRAIgaryQHfplJ9xJSKFywyaSMHuuwZYsoMTAvRvfm51qIGECIA5061zWeyfMPX9hEl_U6f9J0tr7GTJMKyPf5XNrJb5fb5i").unwrap(); |         let deobf_sig = deobf.deobfuscate_sig("GOqGOqGOq0QJ8wRAIgaryQHfplJ9xJSKFywyaSMHuuwZYsoMTAvRvfm51qIGECIA5061zWeyfMPX9hEl_U6f9J0tr7GTJMKyPf5XNrJb5fb5i").unwrap(); | ||||||
|         println!("{}", deobf_sig); |         println!("{}", deobf_sig); | ||||||
|  |  | ||||||
|  | @ -27,8 +27,8 @@ fn get_download_range(offset: u64, size: Option<u64>) -> Range<u64> { | ||||||
|     let chunk_size = rng.gen_range(CHUNK_SIZE_MIN..CHUNK_SIZE_MAX); |     let chunk_size = rng.gen_range(CHUNK_SIZE_MIN..CHUNK_SIZE_MAX); | ||||||
|     let mut chunk_end = offset + chunk_size; |     let mut chunk_end = offset + chunk_size; | ||||||
| 
 | 
 | ||||||
|     if size.is_some() { |     if let Some(size) = size { | ||||||
|         chunk_end = chunk_end.min(size.unwrap() - 1) |         chunk_end = chunk_end.min(size - 1) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Range { |     Range { | ||||||
|  | @ -41,7 +41,7 @@ fn parse_cr_header(cr_header: &str) -> Result<(u64, u64)> { | ||||||
|     static PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r#"bytes (\d+)-(\d+)/(\d+)"#).unwrap()); |     static PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r#"bytes (\d+)-(\d+)/(\d+)"#).unwrap()); | ||||||
| 
 | 
 | ||||||
|     let captures = some_or_bail!( |     let captures = some_or_bail!( | ||||||
|         PATTERN.captures(&cr_header).ok().flatten(), |         PATTERN.captures(cr_header).ok().flatten(), | ||||||
|         Err(anyhow!( |         Err(anyhow!( | ||||||
|             "Content-Range header '{}' does not match pattern.", |             "Content-Range header '{}' does not match pattern.", | ||||||
|             cr_header |             cr_header | ||||||
|  | @ -77,10 +77,7 @@ async fn download_single_file<P: Into<PathBuf>>( | ||||||
|     let (url_base, url_params) = util::url_to_params(url)?; |     let (url_base, url_params) = util::url_to_params(url)?; | ||||||
|     let is_gvideo = url_base.ends_with(".googlevideo.com/videoplayback"); |     let is_gvideo = url_base.ends_with(".googlevideo.com/videoplayback"); | ||||||
|     if is_gvideo { |     if is_gvideo { | ||||||
|         size = url_params |         size = url_params.get("clen").and_then(|s| s.parse::<u64>().ok()); | ||||||
|             .get("clen") |  | ||||||
|             .map(|s| s.parse::<u64>().ok()) |  | ||||||
|             .flatten(); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Check if file is partially downloaded
 |     // Check if file is partially downloaded
 | ||||||
|  | @ -257,6 +254,7 @@ struct StreamDownload { | ||||||
|     video_codec: Option<VideoCodec>, |     video_codec: Option<VideoCodec>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[allow(clippy::too_many_arguments)] | ||||||
| pub async fn download_video( | pub async fn download_video( | ||||||
|     player_data: &VideoPlayer, |     player_data: &VideoPlayer, | ||||||
|     output_dir: &str, |     output_dir: &str, | ||||||
|  | @ -327,7 +325,7 @@ pub async fn download_video( | ||||||
|         _ => { |         _ => { | ||||||
|             let mut downloads: Vec<StreamDownload> = Vec::new(); |             let mut downloads: Vec<StreamDownload> = Vec::new(); | ||||||
| 
 | 
 | ||||||
|             video.map(|v| { |             if let Some(v) = video { | ||||||
|                 downloads.push(StreamDownload { |                 downloads.push(StreamDownload { | ||||||
|                     file: download_dir.join(format!( |                     file: download_dir.join(format!( | ||||||
|                         "{}.video{}", |                         "{}.video{}", | ||||||
|  | @ -338,8 +336,8 @@ pub async fn download_video( | ||||||
|                     video_codec: Some(v.codec), |                     video_codec: Some(v.codec), | ||||||
|                     audio_codec: None, |                     audio_codec: None, | ||||||
|                 }); |                 }); | ||||||
|             }); |             } | ||||||
|             audio.map(|a| { |             if let Some(a) = audio { | ||||||
|                 downloads.push(StreamDownload { |                 downloads.push(StreamDownload { | ||||||
|                     file: download_dir.join(format!( |                     file: download_dir.join(format!( | ||||||
|                         "{}.audio{}", |                         "{}.audio{}", | ||||||
|  | @ -350,7 +348,7 @@ pub async fn download_video( | ||||||
|                     video_codec: None, |                     video_codec: None, | ||||||
|                     audio_codec: Some(a.codec), |                     audio_codec: Some(a.codec), | ||||||
|                 }) |                 }) | ||||||
|             }); |             } | ||||||
| 
 | 
 | ||||||
|             pb.set_message(format!("Downloading {}", title)); |             pb.set_message(format!("Downloading {}", title)); | ||||||
|             download_streams(&downloads, http, pb.clone()).await?; |             download_streams(&downloads, http, pb.clone()).await?; | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| #![allow(dead_code)] | #![allow(dead_code)] | ||||||
|  | #![warn(clippy::todo)] | ||||||
| 
 | 
 | ||||||
| #[macro_use] | #[macro_use] | ||||||
| mod macros; | mod macros; | ||||||
|  | @ -14,7 +15,7 @@ mod util; | ||||||
| 
 | 
 | ||||||
| // pub mod client;
 | // pub mod client;
 | ||||||
| pub mod cache; | pub mod cache; | ||||||
| pub mod client2; | pub mod client; | ||||||
| pub mod download; | pub mod download; | ||||||
| pub mod model; | pub mod model; | ||||||
| pub mod report; | pub mod report; | ||||||
|  |  | ||||||
|  | @ -59,7 +59,7 @@ pub struct VideoStream { | ||||||
|     pub itag: u32, |     pub itag: u32, | ||||||
|     pub bitrate: u32, |     pub bitrate: u32, | ||||||
|     pub average_bitrate: u32, |     pub average_bitrate: u32, | ||||||
|     pub size: u64, |     pub size: Option<u64>, | ||||||
|     pub index_range: Option<Range<u32>>, |     pub index_range: Option<Range<u32>>, | ||||||
|     pub init_range: Option<Range<u32>>, |     pub init_range: Option<Range<u32>>, | ||||||
|     pub width: u32, |     pub width: u32, | ||||||
|  |  | ||||||
|  | @ -9,6 +9,8 @@ use chrono::{DateTime, Local}; | ||||||
| use log::error; | use log::error; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
|  | use crate::deobfuscate::DeobfData; | ||||||
|  | 
 | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | #[derive(Debug, Clone, Serialize, Deserialize)] | ||||||
| pub struct Report { | pub struct Report { | ||||||
|     /// Rust package name (`rustypipe`)
 |     /// Rust package name (`rustypipe`)
 | ||||||
|  | @ -25,9 +27,9 @@ pub struct Report { | ||||||
|     pub error: Option<String>, |     pub error: Option<String>, | ||||||
|     /// Detailed error/warning messages
 |     /// Detailed error/warning messages
 | ||||||
|     pub msgs: Vec<String>, |     pub msgs: Vec<String>, | ||||||
|     // /// Deobfuscation data (only for player requests)
 |     /// Deobfuscation data (only for player requests)
 | ||||||
|     // #[serde(skip_serializing_if = "Option::is_none")]
 |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     // pub deobf_data: Option<DeobfData>,
 |     pub deobf_data: Option<DeobfData>, | ||||||
|     /// HTTP request data
 |     /// HTTP request data
 | ||||||
|     pub http_request: HTTPRequest, |     pub http_request: HTTPRequest, | ||||||
| } | } | ||||||
|  | @ -84,7 +86,7 @@ impl JsonFileReporter { | ||||||
| impl Default for JsonFileReporter { | impl Default for JsonFileReporter { | ||||||
|     fn default() -> Self { |     fn default() -> Self { | ||||||
|         Self { |         Self { | ||||||
|             path: Path::new("RustyPipeReports").to_path_buf(), |             path: Path::new("rustypipe_reports").to_path_buf(), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -96,10 +98,12 @@ impl Reporter for JsonFileReporter { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[cfg(feature = "yaml")] | ||||||
| pub struct YamlFileReporter { | pub struct YamlFileReporter { | ||||||
|     path: PathBuf, |     path: PathBuf, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[cfg(feature = "yaml")] | ||||||
| impl YamlFileReporter { | impl YamlFileReporter { | ||||||
|     pub fn new<P: AsRef<Path>>(path: P) -> Self { |     pub fn new<P: AsRef<Path>>(path: P) -> Self { | ||||||
|         Self { |         Self { | ||||||
|  | @ -114,14 +118,16 @@ impl YamlFileReporter { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[cfg(feature = "yaml")] | ||||||
| impl Default for YamlFileReporter { | impl Default for YamlFileReporter { | ||||||
|     fn default() -> Self { |     fn default() -> Self { | ||||||
|         Self { |         Self { | ||||||
|             path: Path::new("RustyPipeReports").to_path_buf(), |             path: Path::new("rustypipe_reports").to_path_buf(), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[cfg(feature = "yaml")] | ||||||
| impl Reporter for YamlFileReporter { | impl Reporter for YamlFileReporter { | ||||||
|     fn report(&self, report: &Report) { |     fn report(&self, report: &Report) { | ||||||
|         self._report(report) |         self._report(report) | ||||||
|  |  | ||||||
|  | @ -1,5 +1,36 @@ | ||||||
| pub mod range; |  | ||||||
| pub mod text; | pub mod text; | ||||||
|  | 
 | ||||||
|  | mod range; | ||||||
| mod vec_log_err; | mod vec_log_err; | ||||||
| 
 | 
 | ||||||
|  | pub use range::Range; | ||||||
| pub use vec_log_err::VecLogError; | pub use vec_log_err::VecLogError; | ||||||
|  | 
 | ||||||
|  | use std::fmt::Debug; | ||||||
|  | 
 | ||||||
|  | #[derive(Clone)] | ||||||
|  | pub struct MapResult<T> { | ||||||
|  |     pub c: T, | ||||||
|  |     pub warnings: Vec<String>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl<T> Debug for MapResult<T> | ||||||
|  | where | ||||||
|  |     T: Debug, | ||||||
|  | { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         self.c.fmt(f) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl<T> Default for MapResult<T> | ||||||
|  | where | ||||||
|  |     T: Default, | ||||||
|  | { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         Self { | ||||||
|  |             c: Default::default(), | ||||||
|  |             warnings: Vec::new(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -223,11 +223,7 @@ impl<'de> DeserializeAs<'de, Vec<TextLink>> for TextLinks { | ||||||
|         D: Deserializer<'de>, |         D: Deserializer<'de>, | ||||||
|     { |     { | ||||||
|         let link = TextLinkInternal::deserialize(deserializer)?; |         let link = TextLinkInternal::deserialize(deserializer)?; | ||||||
|         Ok(link |         Ok(link.runs.iter().filter_map(map_text_linkrun).collect()) | ||||||
|             .runs |  | ||||||
|             .iter() |  | ||||||
|             .filter_map(|r| map_text_linkrun(r)) |  | ||||||
|             .collect()) |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ use serde::{ | ||||||
| }; | }; | ||||||
| use serde_with::{de::DeserializeAsWrap, DeserializeAs}; | use serde_with::{de::DeserializeAsWrap, DeserializeAs}; | ||||||
| 
 | 
 | ||||||
| use crate::client2::MapResult; | use super::MapResult; | ||||||
| 
 | 
 | ||||||
| pub struct VecLogError<T>(PhantomData<T>); | pub struct VecLogError<T>(PhantomData<T>); | ||||||
| 
 | 
 | ||||||
|  | @ -89,7 +89,7 @@ mod tests { | ||||||
|     use serde::Deserialize; |     use serde::Deserialize; | ||||||
|     use serde_with::serde_as; |     use serde_with::serde_as; | ||||||
| 
 | 
 | ||||||
|     use crate::client2::MapResult; |     use crate::serializer::MapResult; | ||||||
| 
 | 
 | ||||||
|     #[serde_as] |     #[serde_as] | ||||||
|     #[derive(Debug, Deserialize)] |     #[derive(Debug, Deserialize)] | ||||||
|  |  | ||||||
|  | @ -52,24 +52,24 @@ impl Mul<u8> for TimeAgo { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl Into<DateTime<Local>> for TimeAgo { | impl From<TimeAgo> for DateTime<Local> { | ||||||
|     fn into(self) -> DateTime<Local> { |     fn from(ta: TimeAgo) -> Self { | ||||||
|         let ts = Local::now(); |         let ts = Local::now(); | ||||||
|         match self.unit { |         match ta.unit { | ||||||
|             TimeUnit::Second => ts - Duration::seconds(self.n as i64), |             TimeUnit::Second => ts - Duration::seconds(ta.n as i64), | ||||||
|             TimeUnit::Minute => ts - Duration::minutes(self.n as i64), |             TimeUnit::Minute => ts - Duration::minutes(ta.n as i64), | ||||||
|             TimeUnit::Hour => ts - Duration::hours(self.n as i64), |             TimeUnit::Hour => ts - Duration::hours(ta.n as i64), | ||||||
|             TimeUnit::Day => ts - Duration::days(self.n as i64), |             TimeUnit::Day => ts - Duration::days(ta.n as i64), | ||||||
|             TimeUnit::Week => ts - Duration::weeks(self.n as i64), |             TimeUnit::Week => ts - Duration::weeks(ta.n as i64), | ||||||
|             TimeUnit::Month => chronoutil::shift_months(ts, -(self.n as i32)), |             TimeUnit::Month => chronoutil::shift_months(ts, -(ta.n as i32)), | ||||||
|             TimeUnit::Year => chronoutil::shift_years(ts, -(self.n as i32)), |             TimeUnit::Year => chronoutil::shift_years(ts, -(ta.n as i32)), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl Into<DateTime<Local>> for ParsedDate { | impl From<ParsedDate> for DateTime<Local> { | ||||||
|     fn into(self) -> DateTime<Local> { |     fn from(date: ParsedDate) -> Self { | ||||||
|         match self { |         match date { | ||||||
|             ParsedDate::Absolute(date) => Local |             ParsedDate::Absolute(date) => Local | ||||||
|                 .from_local_datetime(&NaiveDateTime::new(date, NaiveTime::from_hms(0, 0, 0))) |                 .from_local_datetime(&NaiveDateTime::new(date, NaiveTime::from_hms(0, 0, 0))) | ||||||
|                 .unwrap(), |                 .unwrap(), | ||||||
|  | @ -103,29 +103,23 @@ fn parse_ta_token(entry: &dictionary::Entry, nd: bool, filtered_str: &str) -> Op | ||||||
| 
 | 
 | ||||||
|     if entry.by_char { |     if entry.by_char { | ||||||
|         filtered_str.chars().find_map(|word| { |         filtered_str.chars().find_map(|word| { | ||||||
|             tokens |             tokens.get(&word.to_string()).and_then(|t| match t.unit { | ||||||
|                 .get(&word.to_string()) |  | ||||||
|                 .map(|t| match t.unit { |  | ||||||
|                 Some(unit) => Some(TimeAgo { n: t.n * qu, unit }), |                 Some(unit) => Some(TimeAgo { n: t.n * qu, unit }), | ||||||
|                 None => { |                 None => { | ||||||
|                     qu = t.n; |                     qu = t.n; | ||||||
|                     None |                     None | ||||||
|                 } |                 } | ||||||
|             }) |             }) | ||||||
|                 .flatten() |  | ||||||
|         }) |         }) | ||||||
|     } else { |     } else { | ||||||
|         filtered_str.split_whitespace().find_map(|word| { |         filtered_str.split_whitespace().find_map(|word| { | ||||||
|             tokens |             tokens.get(word).and_then(|t| match t.unit { | ||||||
|                 .get(word) |  | ||||||
|                 .map(|t| match t.unit { |  | ||||||
|                 Some(unit) => Some(TimeAgo { n: t.n * qu, unit }), |                 Some(unit) => Some(TimeAgo { n: t.n * qu, unit }), | ||||||
|                 None => { |                 None => { | ||||||
|                     qu = t.n; |                     qu = t.n; | ||||||
|                     None |                     None | ||||||
|                 } |                 } | ||||||
|             }) |             }) | ||||||
|                 .flatten() |  | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -137,7 +131,7 @@ fn parse_textual_month(entry: &dictionary::Entry, filtered_str: &str) -> Option< | ||||||
|     } else { |     } else { | ||||||
|         filtered_str |         filtered_str | ||||||
|             .split_whitespace() |             .split_whitespace() | ||||||
|             .find_map(|word| entry.months.get(word).map(|n| *n)) |             .find_map(|word| entry.months.get(word).copied()) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -145,7 +139,7 @@ pub fn parse_timeago(lang: Language, textual_date: &str) -> Option<TimeAgo> { | ||||||
|     let entry = dictionary::entry(lang); |     let entry = dictionary::entry(lang); | ||||||
|     let filtered_str = filter_str(textual_date); |     let filtered_str = filter_str(textual_date); | ||||||
| 
 | 
 | ||||||
|     let qu: u8 = util::parse_numeric(&textual_date).unwrap_or(1); |     let qu: u8 = util::parse_numeric(textual_date).unwrap_or(1); | ||||||
| 
 | 
 | ||||||
|     parse_ta_token(&entry, false, &filtered_str).map(|ta| ta * qu) |     parse_ta_token(&entry, false, &filtered_str).map(|ta| ta * qu) | ||||||
| } | } | ||||||
|  | @ -163,8 +157,7 @@ pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option<ParsedDa | ||||||
|     match nums.len() { |     match nums.len() { | ||||||
|         0 => match parse_ta_token(&entry, true, &filtered_str) { |         0 => match parse_ta_token(&entry, true, &filtered_str) { | ||||||
|             Some(timeago) => Some(ParsedDate::Relative(timeago)), |             Some(timeago) => Some(ParsedDate::Relative(timeago)), | ||||||
|             None => parse_ta_token(&entry, false, &filtered_str) |             None => parse_ta_token(&entry, false, &filtered_str).map(ParsedDate::Relative), | ||||||
|                 .map(|timeago| ParsedDate::Relative(timeago)), |  | ||||||
|         }, |         }, | ||||||
|         1 => parse_ta_token(&entry, false, &filtered_str) |         1 => parse_ta_token(&entry, false, &filtered_str) | ||||||
|             .map(|timeago| ParsedDate::Relative(timeago * nums[0] as u8)), |             .map(|timeago| ParsedDate::Relative(timeago * nums[0] as u8)), | ||||||
|  | @ -189,7 +182,7 @@ pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option<ParsedDa | ||||||
|                 match (y, m, d) { |                 match (y, m, d) { | ||||||
|                     (Some(y), Some(m), Some(d)) => { |                     (Some(y), Some(m), Some(d)) => { | ||||||
|                         NaiveDate::from_ymd_opt(y.into(), m.into(), d.into()) |                         NaiveDate::from_ymd_opt(y.into(), m.into(), d.into()) | ||||||
|                             .map(|d| ParsedDate::Absolute(d)) |                             .map(ParsedDate::Absolute) | ||||||
|                     } |                     } | ||||||
|                     _ => None, |                     _ => None, | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  | @ -41,16 +41,15 @@ pub fn generate_content_playback_nonce() -> String { | ||||||
| ///
 | ///
 | ||||||
| /// `example.com/api?k1=v1&k2=v2 => example.com/api; {k1: v1, k2: v2}`
 | /// `example.com/api?k1=v1&k2=v2 => example.com/api; {k1: v1, k2: v2}`
 | ||||||
| pub fn url_to_params(url: &str) -> Result<(String, BTreeMap<String, String>)> { | pub fn url_to_params(url: &str) -> Result<(String, BTreeMap<String, String>)> { | ||||||
|     let parsed_url = Url::parse(url)?; |     let mut parsed_url = Url::parse(url)?; | ||||||
|     let url_params: BTreeMap<String, String> = parsed_url |     let url_params: BTreeMap<String, String> = parsed_url | ||||||
|         .query_pairs() |         .query_pairs() | ||||||
|         .map(|(k, v)| (k.to_string(), v.to_string())) |         .map(|(k, v)| (k.to_string(), v.to_string())) | ||||||
|         .collect(); |         .collect(); | ||||||
| 
 | 
 | ||||||
|     let mut url_base = parsed_url.clone(); |     parsed_url.set_query(None); | ||||||
|     url_base.set_query(None); |  | ||||||
| 
 | 
 | ||||||
|     Ok((url_base.to_string(), url_params)) |     Ok((parsed_url.to_string(), url_params)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Parse a string after removing all non-numeric characters
 | /// Parse a string after removing all non-numeric characters
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue