1pub mod utils {
2 use crate::{
3 common::types::AudioFormat,
4 sources::{
5 http::reader::HttpReader,
6 youtube::{cipher::YouTubeCipherManager, hls::HlsReader, reader::YoutubeReader},
7 },
8 };
9 use std::sync::Arc;
10 use symphonia::core::io::MediaSource;
11 pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
12 pub fn detect_audio_kind(url: &str, is_hls: bool) -> AudioFormat {
13 if is_hls {
14 AudioFormat::Aac
15 } else {
16 AudioFormat::from_url(url)
17 }
18 }
19 pub async fn create_reader(
20 url: &str,
21 client_name: &str,
22 local_addr: Option<std::net::IpAddr>,
23 proxy: Option<crate::config::HttpProxyConfig>,
24 cipher_manager: Arc<YouTubeCipherManager>,
25 ) -> AnyResult<Box<dyn MediaSource>> {
26 if url.contains(".m3u8") || url.contains("/playlist") {
27 Ok(Box::new(
28 HlsReader::new(url, local_addr, Some(cipher_manager), None, proxy).await?,
29 ))
30 } else if client_name == "TV" {
31 Ok(Box::new(YoutubeReader::new(url, local_addr, proxy).await?))
32 } else {
33 Ok(Box::new(HttpReader::new(url, local_addr, proxy).await?))
34 }
35 }
36 type AnyResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
37 pub fn parse_playability_status(body: &serde_json::Value) -> Result<(), String> {
38 let playability = body
39 .get("playabilityStatus")
40 .and_then(|p| p.get("status"))
41 .and_then(|s| s.as_str())
42 .unwrap_or("UNKNOWN");
43 if playability == "OK" {
44 return Ok(());
45 }
46 let p = body.get("playabilityStatus");
47 let reason = p
48 .and_then(|p| p.get("reason"))
49 .and_then(|r| r.as_str())
50 .unwrap_or("unknown reason");
51 match playability {
52 "ERROR" => Err(reason.to_string()),
53 "UNPLAYABLE" => {
54 if reason == "unknown reason" {
55 Err("This video is unplayable.".to_string())
56 } else {
57 Err(reason.to_string())
58 }
59 }
60 "LOGIN_REQUIRED" => {
61 if reason.contains("This video is private") {
62 Err("This is a private video.".to_string())
63 } else if reason.contains("This video may be inappropriate for some users") {
64 Err("This video requires age verification.".to_string())
65 } else {
66 Err("This video requires login.".to_string())
67 }
68 }
69 "CONTENT_CHECK_REQUIRED" => Err(reason.to_string()),
70 "LIVE_STREAM_OFFLINE" => {
71 if let Some(err_screen) = p.and_then(|p| p.get("errorScreen"))
72 && err_screen.get("ypcTrailerRenderer").is_some()
73 {
74 return Err("This trailer cannot be loaded.".to_string());
75 }
76 Err(reason.to_string())
77 }
78 _ => Err("This video cannot be viewed anonymously.".to_string()),
79 }
80 }
81}
82pub mod ua {
83 pub mod yt_ua {
84 pub const IOS: &str =
85 "com.google.ios.youtube/21.02.1 (iPhone16,2; U; CPU iOS 18_2 like Mac OS X;)";
86 pub const ANDROID: &str =
87 "com.google.android.youtube/20.01.35 (Linux; U; Android 14) identity";
88 pub const ANDROID_VR: &str = "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro Build/UQ1A.240205.002; wv) \
89 AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 \
90 Chrome/121.0.6167.164 Mobile Safari/537.36 YouTubeVR/1.42.15 (gzip)";
91 pub const TVHTML5: &str = "Mozilla/5.0 (Fuchsia) AppleWebKit/537.36 (KHTML, like Gecko) \
92 Chrome/140.0.0.0 Safari/537.36 CrKey/1.56.500000";
93 pub const MWEB: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) \
94 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1";
95 pub const WEB_EMBEDDED: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
96 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
97 pub const TVHTML5_SIMPLY: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
98 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
99 pub const TVHTML5_UNPLUGGED: &str = "Mozilla/5.0 (Linux armeabi-v7a; Android 7.1.2; Fire OS 6.0) Cobalt/22.lts.3.306369-gold (unlike Gecko) v8/8.8.278.8-jit gles Starboard/13, Amazon_ATV_mediatek8695_2019/NS6294 (Amazon, AFTMM, Wireless) com.amazon.firetv.youtube/22.3.r2.v66.0";
100 }
101 pub fn get_youtube_ua(url: &str) -> Option<&'static str> {
102 if !(url.contains("googlevideo.com") || url.contains("youtube.com")) {
103 return None;
104 }
105 extract_param(url, "c=").and_then(|client| match client {
106 "IOS" => Some(yt_ua::IOS),
107 "ANDROID" => Some(yt_ua::ANDROID),
108 "ANDROID_VR" => Some(yt_ua::ANDROID_VR),
109 "TVHTML5" => Some(yt_ua::TVHTML5),
110 "MWEB" => Some(yt_ua::MWEB),
111 "WEB_EMBEDDED_PLAYER" => Some(yt_ua::WEB_EMBEDDED),
112 "TVHTML5_SIMPLY" => Some(yt_ua::TVHTML5_SIMPLY),
113 "TVHTML5_UNPLUGGED" => Some(yt_ua::TVHTML5_UNPLUGGED),
114 _ => None,
115 })
116 }
117 fn extract_param<'a>(url: &'a str, key: &str) -> Option<&'a str> {
118 let query_start = url.find('?')?;
119 let query = &url[query_start + 1..];
120 for part in query.split('&') {
121 if let Some(val) = part.strip_prefix(key) {
122 return Some(val.split('#').next().unwrap_or(val));
123 }
124 }
125 None
126 }
127}
128pub mod oauth {
129 use crate::common::types::AnyResult;
130 use serde_json::{Value, json};
131 use std::time::{SystemTime, UNIX_EPOCH};
132 use tokio::sync::RwLock;
133 use uuid::Uuid;
134 const CLIENT_ID: &str =
135 "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com";
136 const CLIENT_SECRET: &str = "SboVhoG9s0rNafixCSGGKXAT";
137 const SCOPES: &str = "http://gdata.youtube.com https://www.googleapis.com/auth/youtube";
138 pub struct YouTubeOAuth {
139 refresh_tokens: RwLock<Vec<String>>,
140 current_token_index: RwLock<usize>,
141 access_token: RwLock<Option<String>>,
142 token_expiry: RwLock<u64>,
143 client: reqwest::Client,
144 }
145 impl YouTubeOAuth {
146 pub fn new(refresh_tokens: Vec<String>) -> Self {
147 Self {
148 refresh_tokens: RwLock::new(refresh_tokens),
149 current_token_index: RwLock::new(0),
150 access_token: RwLock::new(None),
151 token_expiry: RwLock::new(0),
152 client: reqwest::Client::new(),
153 }
154 }
155 pub async fn initialize_access_token(self: std::sync::Arc<Self>) {
156 if !self.refresh_tokens.read().await.is_empty() {
157 return;
158 }
159 match self.fetch_device_code().await {
160 Ok(response) => {
161 let verification_url =
162 response["verification_url"].as_str().unwrap_or_default();
163 let user_code = response["user_code"].as_str().unwrap_or_default();
164 let device_code = response["device_code"]
165 .as_str()
166 .unwrap_or_default()
167 .to_string();
168 let interval = response["interval"].as_u64().unwrap_or(5);
169 let inner_width = 60;
170 let top_border = format!(" ┌{}┐", "─".repeat(inner_width));
171 let sep_border = format!(" ├{}┤", "─".repeat(inner_width));
172 let bot_border = format!(" └{}┘", "─".repeat(inner_width));
173 let warning = "!!! USE A BURNER ACCOUNT FOR YOUTUBE OAUTH !!!";
174 let warning_pad = inner_width.saturating_sub(warning.len());
175 let warning_left = warning_pad / 2;
176 let warning_right = warning_pad - warning_left;
177 crate::log_println!("\n\x1b[1;33m{}\x1b[0m", top_border);
178 crate::log_println!(
179 "\x1b[1;33m │\x1b[0m{:left$}\x1b[1;31m{}\x1b[0m{:right$}\x1b[1;33m│\x1b[0m",
180 "",
181 warning,
182 "",
183 left = warning_left,
184 right = warning_right
185 );
186 crate::log_println!("\x1b[1;33m{}\x1b[0m", sep_border);
187 let s1_prefix = " 1. Visit: ";
188 let s1_padding =
189 inner_width.saturating_sub(s1_prefix.len() + verification_url.len());
190 crate::log_println!(
191 "\x1b[1;33m │\x1b[0m\x1b[1;36m{}\x1b[0m\x1b[4;34m{}\x1b[0m{:pad$}\x1b[1;33m│\x1b[0m",
192 s1_prefix,
193 verification_url,
194 "",
195 pad = s1_padding
196 );
197 let s2_prefix = " 2. Enter code: ";
198 let s2_code = format!(" {} ", user_code);
199 let s2_padding = inner_width.saturating_sub(s2_prefix.len() + s2_code.len());
200 crate::log_println!(
201 "\x1b[1;33m │\x1b[0m\x1b[1;36m{}\x1b[0m\x1b[1;42;30m{}\x1b[0m{:pad$}\x1b[1;33m│\x1b[0m",
202 s2_prefix,
203 s2_code,
204 "",
205 pad = s2_padding
206 );
207 crate::log_println!("\x1b[1;33m{}\x1b[0m\n", bot_border);
208 let oauth = self.clone();
209 tokio::spawn(async move {
210 oauth.poll_for_token(device_code, interval).await;
211 });
212 }
213 Err(e) => {
214 tracing::error!("Failed to fetch YouTube device code: {}", e);
215 }
216 }
217 }
218 async fn fetch_device_code(&self) -> AnyResult<Value> {
219 let res = self
220 .client
221 .post("https://www.youtube.com/o/oauth2/device/code")
222 .json(&json!({
223 "client_id": CLIENT_ID,
224 "scope": SCOPES,
225 "device_id": Uuid::new_v4().to_string().replace("-", ""),
226 "device_model": "ytlr::"
227 }))
228 .send()
229 .await?;
230 Ok(res.json().await?)
231 }
232 async fn poll_for_token(&self, device_code: String, interval: u64) {
233 let mut interval_timer =
234 tokio::time::interval(std::time::Duration::from_secs(interval));
235 loop {
236 interval_timer.tick().await;
237 match self
238 .fetch_refresh_token_from_device_code(&device_code)
239 .await
240 {
241 Ok(response) => {
242 if let Some(error) = response["error"].as_str() {
243 match error {
244 "authorization_pending" => continue,
245 "slow_down" => {
246 interval_timer = tokio::time::interval(
247 std::time::Duration::from_secs(interval + 5),
248 );
249 continue;
250 }
251 "expired_token" => {
252 tracing::error!(
253 "OAUTH INTEGRATION: The device token has expired. OAuth integration has been canceled."
254 );
255 break;
256 }
257 "access_denied" => {
258 tracing::error!(
259 "OAUTH INTEGRATION: Account linking was denied. OAuth integration has been canceled."
260 );
261 break;
262 }
263 _ => {
264 tracing::error!("Unhandled OAuth2 error: {}", error);
265 break;
266 }
267 }
268 }
269 if let Some(refresh_token) = response["refresh_token"].as_str() {
270 let mut tokens = self.refresh_tokens.write().await;
271 tokens.push(refresh_token.to_string());
272 crate::log_println!(
273 "\x1b[1;32mOAUTH INTEGRATION: Token retrieved successfully!\x1b[0m"
274 );
275 crate::log_println!(
276 "\x1b[1;32mRefresh token:\x1b[0m {}",
277 refresh_token
278 );
279 break;
280 }
281 }
282 Err(e) => {
283 crate::log_println!(
284 "\x1b[1;31mFailed to fetch YouTube OAuth2 token:\x1b[0m {}",
285 e
286 );
287 break;
288 }
289 }
290 }
291 }
292 async fn fetch_refresh_token_from_device_code(
293 &self,
294 device_code: &str,
295 ) -> AnyResult<Value> {
296 let res = self
297 .client
298 .post("https://www.youtube.com/o/oauth2/token")
299 .json(&json!({
300 "client_id": CLIENT_ID,
301 "client_secret": CLIENT_SECRET,
302 "code": device_code,
303 "grant_type": "http://oauth.net/grant_type/device/1.0"
304 }))
305 .send()
306 .await?;
307 Ok(res.json().await?)
308 }
309 pub async fn get_access_token(&self, idx: usize) -> Option<String> {
310 let tokens = self.refresh_tokens.read().await;
311 let max_tokens = tokens.len();
312 if max_tokens == 0 {
313 return None;
314 }
315 let now = SystemTime::now()
316 .duration_since(UNIX_EPOCH)
317 .unwrap()
318 .as_secs();
319 {
320 let expiry = self.token_expiry.read().await;
321 let token = self.access_token.read().await;
322 if let Some(t) = token.as_ref()
323 && now < *expiry
324 {
325 return Some(t.clone());
326 }
327 }
328 let refresh_token = &tokens[idx % max_tokens];
329 if refresh_token.is_empty() {
330 return None;
331 }
332 match self.refresh_token_request(refresh_token).await {
333 Ok((new_token, expires_in)) => {
334 let mut token_store = self.access_token.write().await;
335 let mut expiry_store = self.token_expiry.write().await;
336 *token_store = Some(new_token.clone());
337 *expiry_store = now + expires_in - 30;
338 Some(new_token)
339 }
340 Err(e) => {
341 tracing::error!(
342 "Failed to refresh YouTube token for index {}: {}",
343 idx % max_tokens,
344 e
345 );
346 None
347 }
348 }
349 }
350 async fn refresh_token_request(&self, refresh_token: &str) -> AnyResult<(String, u64)> {
351 let res = self
352 .client
353 .post("https://www.youtube.com/o/oauth2/token")
354 .json(&json!({
355 "client_id": CLIENT_ID,
356 "client_secret": CLIENT_SECRET,
357 "refresh_token": refresh_token,
358 "grant_type": "refresh_token"
359 }))
360 .send()
361 .await?;
362 let status = res.status();
363 if status == 200 {
364 let body: Value = res.json().await?;
365 if let Some(access_token) = body.get("access_token").and_then(|t| t.as_str()) {
366 let expires_in = body
367 .get("expires_in")
368 .and_then(|e| e.as_u64())
369 .unwrap_or(3600);
370 return Ok((access_token.to_string(), expires_in));
371 }
372 }
373 Err(format!("OAuth refresh failed with status: {}", status).into())
374 }
375 pub async fn get_auth_header(&self) -> Option<String> {
376 let tokens = self.refresh_tokens.read().await;
377 if tokens.is_empty() {
378 return None;
379 }
380 let num_tokens = tokens.len();
381 let idx = {
382 let mut current_idx = self.current_token_index.write().await;
383 let val = *current_idx;
384 *current_idx = (val + 1) % num_tokens;
385 val
386 };
387 drop(tokens);
388 self.get_access_token(idx)
389 .await
390 .map(|t| format!("Bearer {}", t))
391 }
392 pub async fn get_refresh_tokens(&self) -> Vec<String> {
393 self.refresh_tokens.read().await.clone()
394 }
395 pub async fn refresh_with_token(
396 &self,
397 refresh_token: &str,
398 ) -> AnyResult<serde_json::Value> {
399 let res = self
400 .client
401 .post("https://www.youtube.com/o/oauth2/token")
402 .json(&json!({
403 "client_id": CLIENT_ID,
404 "client_secret": CLIENT_SECRET,
405 "refresh_token": refresh_token,
406 "grant_type": "refresh_token"
407 }))
408 .send()
409 .await?;
410 let status = res.status();
411 let body: serde_json::Value = res.json().await?;
412 if status.is_success() {
413 Ok(body)
414 } else {
415 Err(format!("OAuth refresh failed: status={}, body={}", status, body).into())
416 }
417 }
418 }
419}
420pub mod cipher {
421 use crate::{common::types::AnyResult, config::sources::YouTubeCipherConfig};
422 use serde_json::{Value, json};
423 use std::time::{Duration, Instant};
424 use tokio::sync::RwLock;
425 #[derive(Clone)]
426 pub struct CachedPlayerScript {
427 pub url: String,
428 pub signature_timestamp: String,
429 pub expire_timestamp_ms: Instant,
430 }
431 pub struct YouTubeCipherManager {
432 config: YouTubeCipherConfig,
433 client: reqwest::Client,
434 cached_player_script: RwLock<Option<CachedPlayerScript>>,
435 }
436 impl YouTubeCipherManager {
437 pub fn new(config: YouTubeCipherConfig) -> Self {
438 Self {
439 config,
440 client: reqwest::Client::new(),
441 cached_player_script: RwLock::new(None),
442 }
443 }
444 pub async fn get_cached_player_script(&self) -> AnyResult<CachedPlayerScript> {
445 {
446 let cache = self.cached_player_script.read().await;
447 if let Some(script) = &*cache
448 && Instant::now() < script.expire_timestamp_ms
449 {
450 return Ok(script.clone());
451 }
452 }
453 let mut cache = self.cached_player_script.write().await;
454 if let Some(script) = &*cache
455 && Instant::now() < script.expire_timestamp_ms
456 {
457 return Ok(script.clone());
458 }
459 let script = self.get_player_script().await?;
460 *cache = Some(script.clone());
461 Ok(script)
462 }
463 async fn get_player_script(&self) -> AnyResult<CachedPlayerScript> {
464 let res = self
465 .client
466 .get("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
467 .header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36")
468 .send()
469 .await?;
470 let text = res.text().await?;
471 let re = regex::Regex::new(r#""jsUrl":"([^"]+)""#)?;
472 let mut script_url = if let Some(caps) = re.captures(&text) {
473 caps[1].to_string()
474 } else {
475 let res = self
476 .client
477 .get("https://www.youtube.com/embed/")
478 .header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36")
479 .send()
480 .await?;
481 let text = res.text().await?;
482 if let Some(caps) = re.captures(&text) {
483 caps[1].to_string()
484 } else {
485 return Err("Could not find jsUrl in player script".into());
486 }
487 };
488 let locale_re = regex::Regex::new(r"/([a-z]{2}_[A-Z]{2})/")?;
489 script_url = locale_re.replace(&script_url, "/en_US/").to_string();
490 let full_url = if script_url.starts_with("http") {
491 script_url
492 } else {
493 format!("https://www.youtube.com{}", script_url)
494 };
495 let signature_timestamp = self.get_timestamp(&full_url).await?;
496 Ok(CachedPlayerScript {
497 url: full_url,
498 signature_timestamp,
499 expire_timestamp_ms: Instant::now() + Duration::from_secs(12 * 60 * 60),
500 })
501 }
502 pub async fn get_timestamp(&self, source_url: &str) -> AnyResult<String> {
503 if let Some(url) = &self.config.url {
504 let mut headers = reqwest::header::HeaderMap::new();
505 if let Some(token) = &self.config.token {
506 headers.insert(reqwest::header::AUTHORIZATION, token.parse()?);
507 }
508 if let Ok(res) = self
509 .client
510 .post(format!("{}/get_sts", url.trim_end_matches('/')))
511 .headers(headers)
512 .json(&json!({ "player_url": source_url }))
513 .send()
514 .await
515 {
516 if res.status() == 200 {
517 if let Ok(body) = res.json::<Value>().await {
518 if let Some(sts) = body.get("sts").and_then(|v| v.as_str()) {
519 return Ok(sts.to_string());
520 }
521 }
522 }
523 }
524 }
525 let res = self.client.get(source_url).send().await?;
526 let text = res.text().await?;
527 let re = regex::Regex::new(r#"(?:signatureTimestamp|sts):(\d+)"#)?;
528 if let Some(caps) = re.captures(&text) {
529 Ok(caps[1].to_string())
530 } else {
531 Err("Could not find STS in player script".into())
532 }
533 }
534 pub async fn get_signature_timestamp(&self) -> AnyResult<u32> {
535 let script = self.get_cached_player_script().await?;
536 script
537 .signature_timestamp
538 .parse::<u32>()
539 .map_err(|e| e.into())
540 }
541 pub async fn resolve_url(
542 &self,
543 stream_url: &str,
544 player_url: &str,
545 n_param: Option<&str>,
546 sig: Option<&str>,
547 ) -> AnyResult<String> {
548 let url = self
549 .config
550 .url
551 .as_ref()
552 .ok_or("Remote cipher URL not configured")?;
553 let player_url = if let Ok(script) = self.get_cached_player_script().await {
554 script.url
555 } else {
556 player_url.to_string()
557 };
558 let mut headers = reqwest::header::HeaderMap::new();
559 if let Some(token) = &self.config.token {
560 headers.insert(reqwest::header::AUTHORIZATION, token.parse()?);
561 }
562 let mut body = json!({
563 "stream_url": stream_url,
564 "player_url": player_url,
565 });
566 if let Some(n) = n_param {
567 body["n_param"] = json!(n);
568 }
569 if let Some(s) = sig {
570 body["encrypted_signature"] = json!(s);
571 body["signature_key"] = json!("sig");
572 }
573 let res = self
574 .client
575 .post(format!("{}/resolve_url", url.trim_end_matches('/')))
576 .headers(headers)
577 .json(&body)
578 .send()
579 .await?;
580 let status = res.status();
581 if status == 200 {
582 let body: Value = res.json().await?;
583 if let Some(resolved) = body.get("resolved_url").and_then(|v| v.as_str()) {
584 return Ok(resolved.to_string());
585 }
586 return Err("Resolved URL missing in response".into());
587 }
588 let err_body = res.text().await?;
589 Err(format!("Failed to resolve URL with status {}: {}", status, err_body).into())
590 }
591 }
592}
593pub mod reader {
594 use super::ua::get_youtube_ua;
595 use crate::{
596 audio::source::{SegmentedSource, create_client},
597 common::types::AnyResult,
598 };
599 use std::io::{Read, Seek, SeekFrom};
600 use symphonia::core::io::MediaSource;
601 pub struct YoutubeReader {
602 inner: SegmentedSource,
603 }
604 impl YoutubeReader {
605 pub async fn new(
606 url: &str,
607 local_addr: Option<std::net::IpAddr>,
608 proxy: Option<crate::config::HttpProxyConfig>,
609 ) -> AnyResult<Self> {
610 let user_agent = get_youtube_ua(url)
611 .map(str::to_string)
612 .unwrap_or_else(crate::common::utils::default_user_agent);
613 let client = create_client(user_agent, local_addr, proxy, None)?;
614 let inner = SegmentedSource::new(client, url).await?;
615 Ok(Self { inner })
616 }
617 }
618 impl Read for YoutubeReader {
619 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
620 self.inner.read(buf)
621 }
622 }
623 impl Seek for YoutubeReader {
624 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
625 self.inner.seek(pos)
626 }
627 }
628 impl MediaSource for YoutubeReader {
629 fn is_seekable(&self) -> bool {
630 self.inner.is_seekable()
631 }
632 fn byte_len(&self) -> Option<u64> {
633 self.inner.byte_len()
634 }
635 }
636}
637pub mod extractor {
638 use crate::protocol::tracks::{Track, TrackInfo};
639 use serde_json::Value;
640 pub fn extract_from_player(body: &Value, source_name: &str) -> Option<Track> {
641 let details = body
642 .get("videoDetails")
643 .or_else(|| body.get("video_details"))?;
644 let video_id = details
645 .get("videoId")
646 .or_else(|| details.get("video_id"))?
647 .as_str()?;
648 let title = details.get("title")?.as_str()?.to_string();
649 let author = details.get("author")?.as_str()?.to_string();
650 let is_stream = details
651 .get("isLiveContent")
652 .or_else(|| details.get("is_live_content"))
653 .and_then(|v| v.as_bool())
654 .unwrap_or(false);
655 let length_seconds = details
656 .get("lengthSeconds")
657 .or_else(|| details.get("length_seconds"))
658 .and_then(|v| v.as_str())
659 .and_then(|s| s.parse::<u64>().ok())
660 .unwrap_or(0);
661 let artwork_url = details
662 .get("thumbnail")
663 .and_then(|t| t.get("thumbnails"))
664 .and_then(|arr| arr.as_array())
665 .and_then(|arr| arr.last())
666 .and_then(|thumb| thumb.get("url"))
667 .and_then(|url| url.as_str())
668 .map(|s| s.to_string());
669 let track = Track::new(TrackInfo {
670 identifier: video_id.to_string(),
671 is_seekable: !is_stream,
672 author,
673 length: if is_stream {
674 9223372036854775807
675 } else {
676 length_seconds * 1000
677 },
678 is_stream,
679 position: 0,
680 title,
681 uri: Some(format!("https://www.youtube.com/watch?v={}", video_id)),
682 artwork_url,
683 isrc: None,
684 source_name: source_name.to_string(),
685 });
686 Some(track)
687 }
688 pub fn extract_from_next(body: &Value, source_name: &str) -> Option<(Vec<Track>, String)> {
689 let contents_root = body.get("contents").and_then(|c| {
690 c.get("singleColumnWatchNextResults")
691 .or_else(|| c.get("singleColumnMusicWatchNextResultsRenderer"))
692 .or_else(|| c.get("twoColumnWatchNextResults"))
693 })?;
694 let playlist_content = contents_root
695 .get("playlist")
696 .and_then(|p| p.get("playlist"))
697 .and_then(|p| p.get("contents"))
698 .and_then(|c| c.as_array())
699 .or_else(|| {
700 contents_root
701 .get("tabbedRenderer")
702 .and_then(|t| t.get("watchNextTabbedResultsRenderer"))
703 .and_then(|w| w.get("tabs"))
704 .and_then(|t| t.get(0))
705 .and_then(|t| t.get("tabRenderer"))
706 .and_then(|t| t.get("content"))
707 .and_then(|c| c.get("musicQueueRenderer"))
708 .and_then(|music_queue| {
709 music_queue
710 .get("content")
711 .and_then(|c| c.get("playlistPanelRenderer"))
712 .and_then(|p| p.get("contents"))
713 .or_else(|| music_queue.get("contents"))
714 .and_then(|c| c.as_array())
715 })
716 })?;
717 if playlist_content.is_empty() {
718 return None;
719 }
720 let mut tracks = Vec::new();
721 for item in playlist_content {
722 if let Some(track) = extract_track(item, source_name) {
723 tracks.push(track);
724 }
725 }
726 if tracks.is_empty() {
727 return None;
728 }
729 let title = contents_root
730 .get("tabbedRenderer")
731 .and_then(|t| t.get("watchNextTabbedResultsRenderer"))
732 .and_then(|t| t.get("tabs"))
733 .and_then(|t| t.get(0))
734 .and_then(|t| t.get("tabRenderer"))
735 .and_then(|t| t.get("content"))
736 .and_then(|c| c.get("musicQueueRenderer"))
737 .and_then(|m| m.get("header"))
738 .and_then(|h| h.get("musicQueueHeaderRenderer"))
739 .and_then(|m| m.get("subtitle"))
740 .and_then(get_text)
741 .or_else(|| {
742 contents_root
743 .get("playlist")
744 .and_then(|p| p.get("playlist"))
745 .and_then(|p| p.get("title"))
746 .and_then(|t| t.as_str())
747 .map(|s| s.to_string())
748 })
749 .unwrap_or_else(|| "Unknown Playlist".to_string());
750 Some((tracks, title))
751 }
752 pub fn extract_from_browse(body: &Value, source_name: &str) -> Option<(Vec<Track>, String)> {
753 let title = body
754 .get("header")
755 .and_then(|h| {
756 h.get("playlistHeaderRenderer")
757 .or_else(|| h.get("musicAlbumReleaseHeaderRenderer"))
758 .or_else(|| h.get("musicDetailHeaderRenderer"))
759 .or_else(|| {
760 h.get("musicEditablePlaylistDetailHeaderRenderer")
761 .and_then(|m| m.get("header"))
762 .and_then(|h| h.get("musicDetailHeaderRenderer"))
763 })
764 })
765 .and_then(|h| h.get("title"))
766 .and_then(get_text)
767 .unwrap_or_else(|| "Unknown Playlist".to_string());
768 let mut tracks = Vec::new();
769 if let Some(section_list) = find_section_list(body)
770 && let Some(contents) = section_list.get("contents").and_then(|c| c.as_array())
771 {
772 for section in contents {
773 if let Some(list) = section
774 .get("itemSectionRenderer")
775 .and_then(|i| i.get("contents"))
776 .and_then(|c| c.as_array())
777 .and_then(|arr| arr.first())
778 .and_then(|first| first.get("playlistVideoListRenderer"))
779 .and_then(|p| p.get("contents"))
780 .and_then(|c| c.as_array())
781 {
782 for item in list {
783 if let Some(track) = extract_track(item, source_name) {
784 tracks.push(track);
785 }
786 }
787 }
788 if let Some(list) = section
789 .get("musicShelfRenderer")
790 .and_then(|s| s.get("contents"))
791 .and_then(|c| c.as_array())
792 {
793 for item in list {
794 if let Some(track) = extract_track(item, source_name) {
795 tracks.push(track);
796 }
797 }
798 }
799 if let Some(shelf) = section.get("musicPlaylistShelfRenderer")
800 && let Some(list) = shelf.get("contents").and_then(|c| c.as_array())
801 {
802 for item in list {
803 if let Some(track) = extract_track(item, source_name) {
804 tracks.push(track);
805 }
806 }
807 }
808 }
809 }
810 if tracks.is_empty()
811 && let Some(contents) = body
812 .get("contents")
813 .and_then(|c| c.get("singleColumnBrowseResultsRenderer"))
814 .and_then(|s| s.get("tabs"))
815 .and_then(|t| t.as_array())
816 .and_then(|t| t.first())
817 .and_then(|t| t.get("tabRenderer"))
818 .and_then(|t| t.get("content"))
819 .and_then(|c| c.get("sectionListRenderer"))
820 .and_then(|s| s.get("contents"))
821 .and_then(|c| c.as_array())
822 .and_then(|c| c.first())
823 .and_then(|c| c.get("musicPlaylistShelfRenderer"))
824 && let Some(list) = contents.get("contents").and_then(|c| c.as_array())
825 {
826 for item in list {
827 if let Some(track) = extract_track(item, source_name) {
828 tracks.push(track);
829 }
830 }
831 }
832 if tracks.is_empty()
833 && let Some(list) = find_music_playlist_shelf(body)
834 {
835 for item in list {
836 if let Some(track) = extract_track(item, source_name) {
837 tracks.push(track);
838 }
839 }
840 }
841 if tracks.is_empty()
842 && let Some(continuation_contents) = body
843 .get("onResponseReceivedActions")
844 .and_then(|a| a.as_array())
845 .and_then(|arr| arr.first())
846 .and_then(|a| a.get("appendContinuationItemsAction"))
847 .and_then(|a| a.get("continuationItems"))
848 .and_then(|c| c.as_array())
849 {
850 for item in continuation_contents {
851 if let Some(track) = extract_track(item, source_name) {
852 tracks.push(track);
853 }
854 }
855 }
856 if tracks.is_empty() {
857 return None;
858 }
859 Some((tracks, title))
860 }
861 fn find_music_playlist_shelf(value: &Value) -> Option<&Vec<Value>> {
862 if let Some(shelf) = value.get("musicPlaylistShelfRenderer") {
863 return shelf.get("contents").and_then(|c| c.as_array());
864 }
865 if let Some(obj) = value.as_object() {
866 for (_, val) in obj {
867 if let Some(list) = find_music_playlist_shelf(val) {
868 return Some(list);
869 }
870 }
871 }
872 if let Some(arr) = value.as_array() {
873 for item in arr {
874 if let Some(list) = find_music_playlist_shelf(item) {
875 return Some(list);
876 }
877 }
878 }
879 None
880 }
881 pub fn find_section_list(value: &Value) -> Option<&Value> {
882 if let Some(list) = value.get("sectionListRenderer") {
883 return Some(list);
884 }
885 if let Some(contents) = value.get("contents")
886 && let Some(list) = find_section_list(contents)
887 {
888 return Some(list);
889 }
890 if let Some(arr) = value.as_array() {
891 for item in arr {
892 if let Some(list) = find_section_list(item) {
893 return Some(list);
894 }
895 }
896 }
897 if let Some(tabs) = value.get("tabs").and_then(|t| t.as_array()) {
898 for tab in tabs {
899 if let Some(content) = tab.get("tabRenderer").and_then(|tr| tr.get("content"))
900 && let Some(list) = find_section_list(content)
901 {
902 return Some(list);
903 }
904 }
905 }
906 if let Some(primary) = value
907 .get("twoColumnSearchResultsRenderer")
908 .and_then(|t| t.get("primaryContents"))
909 {
910 return find_section_list(primary);
911 }
912 None
913 }
914 pub fn extract_track(item: &Value, source_name: &str) -> Option<Track> {
915 let renderer = item
916 .get("videoRenderer")
917 .or_else(|| item.get("compactVideoRenderer"))
918 .or_else(|| item.get("playlistVideoRenderer"))
919 .or_else(|| item.get("musicResponsiveListItemRenderer"))
920 .or_else(|| item.get("musicTwoColumnItemRenderer"))
921 .or_else(|| item.get("playlistPanelVideoRenderer"))
922 .or_else(|| item.get("gridVideoRenderer"))?;
923 let video_id = renderer
924 .get("videoId")
925 .and_then(|v| v.as_str())
926 .or_else(|| {
927 renderer
928 .get("playlistItemData")
929 .and_then(|d| d.get("videoId"))
930 .and_then(|v| v.as_str())
931 })
932 .or_else(|| {
933 renderer
934 .get("doubleTapCommand")
935 .and_then(|c| c.get("watchEndpoint"))
936 .and_then(|w| w.get("videoId"))
937 .and_then(|v| v.as_str())
938 })
939 .or_else(|| {
940 renderer
941 .get("navigationEndpoint")
942 .and_then(|n| n.get("watchEndpoint"))
943 .and_then(|w| w.get("videoId"))
944 .and_then(|v| v.as_str())
945 })?;
946 let title = get_text(renderer.get("title").or_else(|| {
947 renderer
948 .get("flexColumns")
949 .and_then(|c| c.get(0))
950 .and_then(|c| c.get("musicResponsiveListItemFlexColumnRenderer"))
951 .and_then(|r| r.get("text"))
952 })?)
953 .unwrap_or_else(|| "Unknown Title".to_string());
954 let author = extract_author(renderer).unwrap_or_else(|| "Unknown Artist".to_string());
955 let is_stream = renderer
956 .get("isLive")
957 .and_then(|v| v.as_bool())
958 .unwrap_or(false)
959 || renderer
960 .get("badges")
961 .and_then(|b| b.as_array())
962 .map(|arr| {
963 arr.iter().any(|badge| {
964 badge
965 .get("metadataBadgeRenderer")
966 .and_then(|mbr| mbr.get("label"))
967 .and_then(|l| l.as_str())
968 .map(|s| s == "LIVE")
969 .unwrap_or(false)
970 })
971 })
972 .unwrap_or(false);
973 let length_ms = if is_stream {
974 9223372036854775807
975 } else {
976 renderer
977 .get("lengthText")
978 .and_then(get_text)
979 .map(|s| parse_duration(&s))
980 .or_else(|| {
981 renderer
982 .get("lengthSeconds")
983 .and_then(|v| v.as_str())
984 .and_then(|s| s.parse::<i64>().ok())
985 .map(|s| s * 1000)
986 })
987 .unwrap_or(0)
988 };
989 Some(Track::new(TrackInfo {
990 identifier: video_id.to_string(),
991 is_seekable: !is_stream,
992 author,
993 length: length_ms as u64,
994 is_stream,
995 position: 0,
996 title,
997 uri: Some(format!("https://www.youtube.com/watch?v={}", video_id)),
998 artwork_url: get_thumbnail(renderer),
999 isrc: None,
1000 source_name: source_name.to_string(),
1001 }))
1002 }
1003 fn extract_author(renderer: &Value) -> Option<String> {
1004 if let Some(subtitle) = renderer.get("subtitle")
1005 && let Some(text) = get_first_subtitle_run(subtitle)
1006 {
1007 let artist = text.split(" • ").next().unwrap_or(&text).trim();
1008 if !artist.is_empty() {
1009 return Some(artist.to_string());
1010 }
1011 }
1012 if let Some(author) = renderer
1013 .get("menu")
1014 .and_then(|m| m.get("menuRenderer"))
1015 .and_then(|m| m.get("title"))
1016 .and_then(|t| t.get("musicMenuTitleRenderer"))
1017 .and_then(|m| m.get("secondaryText"))
1018 .and_then(get_first_text)
1019 {
1020 return Some(author);
1021 }
1022 if let Some(text) = renderer.get("longBylineText").and_then(get_first_text) {
1023 return Some(text);
1024 }
1025 if let Some(text) = renderer.get("shortBylineText").and_then(get_first_text) {
1026 return Some(text);
1027 }
1028 if let Some(text) = renderer.get("ownerText").and_then(get_first_text) {
1029 return Some(text);
1030 }
1031 if let Some(flex) = renderer
1032 .get("flexColumns")
1033 .and_then(|c| c.get(1))
1034 .and_then(|c| c.get("musicResponsiveListItemFlexColumnRenderer"))
1035 .and_then(|r| r.get("text"))
1036 && let Some(runs) = flex.get("runs").and_then(|r| r.as_array())
1037 && let Some(text) = runs
1038 .first()
1039 .and_then(|r| r.get("text"))
1040 .and_then(|t| t.as_str())
1041 {
1042 return Some(text.to_string());
1043 }
1044 None
1045 }
1046 fn get_first_subtitle_run(subtitle: &Value) -> Option<String> {
1047 if let Some(runs) = subtitle.get("runs").and_then(|r| r.as_array()) {
1048 return runs
1049 .first()
1050 .and_then(|r| r.get("text"))
1051 .and_then(|t| t.as_str())
1052 .map(|s| s.to_string());
1053 }
1054 if let Some(simple_text) = subtitle.get("simpleText").and_then(|v| v.as_str()) {
1055 return Some(simple_text.to_string());
1056 }
1057 if let Some(s) = subtitle.as_str() {
1058 return Some(s.to_string());
1059 }
1060 None
1061 }
1062 fn get_text(obj: &Value) -> Option<String> {
1063 if let Some(s) = obj.as_str() {
1064 return Some(s.to_string());
1065 }
1066 if let Some(simple_text) = obj.get("simpleText").and_then(|v| v.as_str()) {
1067 return Some(simple_text.to_string());
1068 }
1069 if let Some(runs) = obj.get("runs").and_then(|v| v.as_array()) {
1070 let mut text = String::new();
1071 for run in runs {
1072 if let Some(t) = run.get("text").and_then(|v| v.as_str()) {
1073 text.push_str(t);
1074 }
1075 }
1076 return Some(text);
1077 }
1078 None
1079 }
1080 fn get_first_text(obj: &Value) -> Option<String> {
1081 if let Some(s) = obj.as_str() {
1082 return Some(s.to_string());
1083 }
1084 if let Some(simple_text) = obj.get("simpleText").and_then(|v| v.as_str()) {
1085 return Some(simple_text.to_string());
1086 }
1087 if let Some(runs) = obj.get("runs").and_then(|v| v.as_array()) {
1088 return runs
1089 .first()
1090 .and_then(|run| run.get("text"))
1091 .and_then(|t| t.as_str())
1092 .map(|s| s.to_string());
1093 }
1094 None
1095 }
1096 fn parse_duration(s: &str) -> i64 {
1097 let parts: Vec<&str> = s.split(':').collect();
1098 let mut seconds = 0;
1099 for part in parts {
1100 seconds = seconds * 60 + part.parse::<i64>().unwrap_or(0);
1101 }
1102 seconds * 1000
1103 }
1104 fn get_thumbnail(renderer: &Value) -> Option<String> {
1105 renderer
1106 .get("thumbnail")
1107 .and_then(|t| t.get("thumbnails"))
1108 .and_then(|arr| arr.as_array())
1109 .and_then(|arr| arr.last())
1110 .and_then(|thumb| thumb.get("url"))
1111 .and_then(|url| url.as_str())
1112 .map(|s| s.split('?').next().unwrap_or(s).to_string())
1113 }
1114}
1115pub mod track {
1116 use crate::{
1117 config::HttpProxyConfig,
1118 sources::{
1119 playable_track::{PlayableTrack, ResolvedTrack},
1120 youtube::{
1121 cipher::YouTubeCipherManager,
1122 clients::YouTubeClient,
1123 oauth::YouTubeOAuth,
1124 utils::{create_reader, detect_audio_kind},
1125 },
1126 },
1127 };
1128 use async_trait::async_trait;
1129 use std::{net::IpAddr, sync::Arc};
1130 use tracing::{debug, error, info, warn};
1131 pub struct YoutubeTrack {
1132 pub identifier: String,
1133 pub clients: Vec<Arc<dyn YouTubeClient>>,
1134 pub oauth: Arc<YouTubeOAuth>,
1135 pub cipher_manager: Arc<YouTubeCipherManager>,
1136 pub visitor_data: Option<String>,
1137 pub local_addr: Option<IpAddr>,
1138 pub proxy: Option<HttpProxyConfig>,
1139 }
1140 #[async_trait]
1141 impl PlayableTrack for YoutubeTrack {
1142 fn supports_seek(&self) -> bool {
1143 true
1144 }
1145 async fn resolve(&self) -> Result<ResolvedTrack, String> {
1146 let context = serde_json::json!({ "visitorData": self.visitor_data });
1147 let mut last_error = String::from("No clients available");
1148 for client in &self.clients {
1149 let name = client.name().to_string();
1150 let url = match client
1151 .get_track_url(
1152 &self.identifier,
1153 &context,
1154 self.cipher_manager.clone(),
1155 self.oauth.clone(),
1156 )
1157 .await
1158 {
1159 Ok(Some(url)) => {
1160 info!(
1161 "YoutubeTrack: resolved '{}' using '{name}'",
1162 self.identifier
1163 );
1164 url
1165 }
1166 Ok(None) => {
1167 debug!(
1168 "YoutubeTrack: client '{name}' returned no URL for '{}'",
1169 self.identifier
1170 );
1171 continue;
1172 }
1173 Err(e) => {
1174 warn!(
1175 "YoutubeTrack: client '{name}' failed for '{}': {e}",
1176 self.identifier
1177 );
1178 last_error = e.to_string();
1179 continue;
1180 }
1181 };
1182 let is_hls = url.contains(".m3u8") || url.contains("/playlist");
1183 let hint = Some(detect_audio_kind(&url, is_hls));
1184 let proxy = self.proxy.clone();
1185 let local_addr = self.local_addr;
1186 let cipher = self.cipher_manager.clone();
1187 let url_clone = url.clone();
1188 let name_clone = name.clone();
1189 match create_reader(&url_clone, &name_clone, local_addr, proxy, cipher).await {
1190 Ok(reader) => return Ok(ResolvedTrack::new(reader, hint)),
1191 Err(e) => {
1192 warn!("YoutubeTrack: reader failed for '{name}': {e} — trying next client");
1193 last_error = e.to_string();
1194 continue;
1195 }
1196 }
1197 }
1198 error!(
1199 "YoutubeTrack: all clients failed for '{}': {last_error}",
1200 self.identifier
1201 );
1202 Err(format!("All clients failed: {last_error}"))
1203 }
1204 }
1205
1206 #[allow(dead_code)]
1207 fn is_playability_error(msg: &str) -> bool {
1208 msg.contains("This video ")
1209 || msg.contains("This is a private video")
1210 || msg.contains("This trailer cannot be loaded")
1211 }
1212}
1213use crate::{
1214 common::types::SharedRw,
1215 config::sources::YouTubeConfig,
1216 protocol::tracks::*,
1217 sources::{SourcePlugin, playable_track::BoxedTrack},
1218};
1219use async_trait::async_trait;
1220use regex::Regex;
1221use serde_json::{Value, json};
1222use std::sync::Arc;
1223use tokio::sync::RwLock;
1224use tracing::{debug, warn};
1225pub mod clients;
1226pub mod hls;
1227use cipher::YouTubeCipherManager;
1228use clients::{
1229 YouTubeClient, android::AndroidClient, android_vr::AndroidVrClient, ios::IosClient,
1230 music_android::MusicAndroidClient, mweb::MWebClient, tv::TvClient, tv_cast::TvCastClient,
1231 tv_embedded::TvEmbeddedClient, tv_simply::TvSimplyClient, tv_unplugged::TvUnpluggedClient,
1232 web::WebClient, web_embedded::WebEmbeddedClient, web_parent_tools::WebParentToolsClient,
1233 web_remix::WebRemixClient,
1234};
1235use oauth::YouTubeOAuth;
1236pub struct YouTubeSource {
1237 search_prefixes: Vec<String>,
1238 rec_prefixes: Vec<String>,
1239 url_regex: Regex,
1240 search_clients: Vec<Arc<dyn YouTubeClient>>,
1241 music_search_clients: Vec<Arc<dyn YouTubeClient>>,
1242 playback_clients: Vec<Arc<dyn YouTubeClient>>,
1243 resolve_clients: Vec<Arc<dyn YouTubeClient>>,
1244 oauth: Arc<YouTubeOAuth>,
1245 cipher_manager: Arc<YouTubeCipherManager>,
1246 visitor_data: SharedRw<Option<String>>,
1247 #[allow(dead_code)]
1248 http: Arc<reqwest::Client>,
1249}
1250pub struct YoutubeStreamContext {
1251 pub clients: Vec<Arc<dyn YouTubeClient>>,
1252 pub oauth: Arc<YouTubeOAuth>,
1253 pub cipher_manager: Arc<YouTubeCipherManager>,
1254 pub visitor_data: SharedRw<Option<String>>,
1255 pub http: Arc<reqwest::Client>,
1256}
1257impl YouTubeSource {
1258 pub fn new(config: Option<YouTubeConfig>, http: Arc<reqwest::Client>) -> Self {
1259 let config = config.unwrap_or_default();
1260 let oauth = Arc::new(YouTubeOAuth::new(config.refresh_tokens.clone()));
1261 let cipher_manager = Arc::new(YouTubeCipherManager::new(config.cipher.clone()));
1262 if config.get_oauth_token && config.refresh_tokens.is_empty() {
1263 let oauth_clone = oauth.clone();
1264 tokio::spawn(async move {
1265 oauth_clone.initialize_access_token().await;
1266 });
1267 }
1268 let cm_clone = cipher_manager.clone();
1269 tokio::spawn(async move {
1270 debug!("YouTubeSource: Warming cipher cache...");
1271 if let Err(e) = cm_clone.get_cached_player_script().await {
1272 warn!("YouTubeSource: Failed to warm cipher cache: {}", e);
1273 } else {
1274 debug!("YouTubeSource: Cipher cache warmed.");
1275 }
1276 });
1277 let visitor_data = Arc::new(RwLock::new(None));
1278 let vd_clone = visitor_data.clone();
1279 let http_clone = http.clone();
1280 tokio::spawn(async move {
1281 loop {
1282 if let Some(vd) = Self::refresh_visitor_data(&http_clone).await {
1283 let mut lock = vd_clone.write().await;
1284 *lock = Some(vd);
1285 tracing::debug!("YouTube visitorData refreshed.");
1286 } else {
1287 tracing::warn!("Failed to refresh YouTube visitorData.");
1288 }
1289 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
1290 }
1291 });
1292 let cipher_url = config.cipher.url.clone();
1293 let cipher_token = config.cipher.token.clone();
1294 let create_client = |name: &str| -> Option<Arc<dyn YouTubeClient>> {
1295 match name.to_uppercase().as_str() {
1296 "WEB" => Some(Arc::new(WebClient::with_cipher_url(
1297 http.clone(),
1298 cipher_url.clone(),
1299 cipher_token.clone(),
1300 ))),
1301 "WEB_REMIX" | "REMIX" | "MUSIC_WEB" => {
1302 Some(Arc::new(WebRemixClient::new(http.clone())))
1303 }
1304 "MWEB" => Some(Arc::new(MWebClient::new(http.clone()))),
1305 "ANDROID" => Some(Arc::new(AndroidClient::new(http.clone()))),
1306 "IOS" => Some(Arc::new(IosClient::new(http.clone()))),
1307 "TV" | "TVHTML5" => Some(Arc::new(TvClient::new(http.clone()))),
1308 "TV_CAST" | "TVHTML5_CAST" => Some(Arc::new(TvCastClient::new(http.clone()))),
1309 "TVHTML5_SIMPLY_EMBEDDED_PLAYER" | "TV_EMBEDDED" => {
1310 Some(Arc::new(TvEmbeddedClient::new(http.clone())))
1311 }
1312 "TVHTML5_SIMPLY" | "TV_SIMPLY" => Some(Arc::new(TvSimplyClient::new(http.clone()))),
1313 "TVHTML5_UNPLUGGED" | "TV_UNPLUGGED" => {
1314 Some(Arc::new(TvUnpluggedClient::new(http.clone())))
1315 }
1316 "MUSIC" | "MUSIC_ANDROID" | "ANDROID_MUSIC" => {
1317 Some(Arc::new(MusicAndroidClient::new(http.clone())))
1318 }
1319 "ANDROID_VR" | "ANDROIDVR" => Some(Arc::new(AndroidVrClient::new(http.clone()))),
1320 "WEB_EMBEDDED" | "WEBEMBEDDED" => {
1321 Some(Arc::new(WebEmbeddedClient::new(http.clone())))
1322 }
1323 "WEB_PARENT_TOOLS" | "WEBPARENTTOOLS" => {
1324 Some(Arc::new(WebParentToolsClient::new(http.clone())))
1325 }
1326 _ => {
1327 tracing::warn!("Unknown YouTube client: {}", name);
1328 None
1329 }
1330 }
1331 };
1332 let mut search_clients = Vec::new();
1333 for name in &config.clients.search {
1334 if let Some(client) = create_client(name) {
1335 search_clients.push(client);
1336 }
1337 }
1338 if search_clients.is_empty() {
1339 tracing::warn!("No valid YouTube search clients configured! Fallback to Web.");
1340 search_clients.push(Arc::new(WebClient::new(http.clone())));
1341 }
1342 let search_client_names: Vec<String> = search_clients
1343 .iter()
1344 .map(|c| c.name().to_string())
1345 .collect();
1346 tracing::debug!(
1347 "YouTube Search Clients initialized: {:?}",
1348 search_client_names
1349 );
1350 let mut playback_clients = Vec::new();
1351 for name in &config.clients.playback {
1352 if let Some(client) = create_client(name) {
1353 playback_clients.push(client);
1354 }
1355 }
1356 if playback_clients.is_empty() {
1357 tracing::warn!("No valid YouTube playback clients configured! Fallback to Web.");
1358 playback_clients.push(Arc::new(WebClient::new(http.clone())));
1359 }
1360 let mut resolve_clients = Vec::new();
1361 for name in &config.clients.resolve {
1362 if let Some(client) = create_client(name) {
1363 resolve_clients.push(client);
1364 }
1365 }
1366 if resolve_clients.is_empty() {
1367 tracing::warn!("No valid YouTube resolve clients configured! Fallback to Web.");
1368 resolve_clients.push(Arc::new(WebClient::new(http.clone())));
1369 }
1370 let music_search_clients: Vec<Arc<dyn YouTubeClient>> = vec![
1371 Arc::new(MusicAndroidClient::new(http.clone())),
1372 Arc::new(WebRemixClient::new(http.clone())),
1373 ];
1374 tracing::info!(
1375 "YouTube source initialized with {} search, {} playback, and {} resolve clients.",
1376 search_clients.len(),
1377 playback_clients.len(),
1378 resolve_clients.len()
1379 );
1380 Self {
1381 search_prefixes: vec!["ytsearch:".to_string(), "ytmsearch:".to_string()],
1382 rec_prefixes: vec!["ytrec:".to_string()],
1383 url_regex: Regex::new(r"(?:youtube\.com|youtu\.be)").unwrap(),
1384 search_clients,
1385 music_search_clients,
1386 playback_clients,
1387 resolve_clients,
1388 oauth,
1389 cipher_manager,
1390 visitor_data,
1391 http,
1392 }
1393 }
1394 pub fn stream_context(&self) -> Arc<YoutubeStreamContext> {
1395 Arc::new(YoutubeStreamContext {
1396 clients: self.playback_clients.clone(),
1397 oauth: self.oauth.clone(),
1398 cipher_manager: self.cipher_manager.clone(),
1399 visitor_data: self.visitor_data.clone(),
1400 http: self.http.clone(),
1401 })
1402 }
1403 async fn refresh_visitor_data(http: &reqwest::Client) -> Option<String> {
1404 match http
1405 .get("https://www.youtube.com/embed")
1406 .header("Cookie", "YSC=cz5kYp3ZuIE; VISITOR_INFO1_LIVE=U-0T5oUyzf8;")
1407 .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
1408 .send()
1409 .await
1410 {
1411 Ok(res) if res.status().is_success() => {
1412 if let Ok(text) = res.text().await {
1413 let re = regex::Regex::new(r#""VISITOR_DATA":"([^"]+)""#).ok();
1414 if let Some(vd) = re.as_ref().and_then(|r| r.captures(&text)).and_then(|c| c.get(1)) {
1415 let raw = vd.as_str();
1416 let decoded = urlencoding::decode(raw)
1417 .map(|s| s.into_owned())
1418 .unwrap_or_else(|_| raw.to_string());
1419 tracing::debug!("YouTube: visitorData refreshed from embed page.");
1420 return Some(decoded);
1421 }
1422 }
1423 }
1424 Ok(res) => {
1425 tracing::warn!("YouTube embed page returned status {}; falling back to guide API.", res.status());
1426 }
1427 Err(e) => {
1428 tracing::warn!("YouTube embed page request failed: {}; falling back to guide API.", e);
1429 }
1430 }
1431 let body = json!({
1432 "context": {
1433 "client": {
1434 "clientName": "WEB",
1435 "clientVersion": "2.20260114.01.00",
1436 "hl": "en",
1437 "gl": "US"
1438 }
1439 }
1440 });
1441 match http
1442 .post("https://www.youtube.com/youtubei/v1/guide")
1443 .json(&body)
1444 .send()
1445 .await
1446 {
1447 Ok(res) => {
1448 if let Ok(json) = res.json::<Value>().await
1449 && let Some(vd) = json
1450 .get("responseContext")
1451 .and_then(|rc| rc.get("visitorData"))
1452 .and_then(|vd| vd.as_str())
1453 {
1454 let decoded = urlencoding::decode(vd)
1455 .map(|s| s.into_owned())
1456 .unwrap_or_else(|_| vd.to_string());
1457 tracing::debug!("YouTube: visitorData refreshed via guide API fallback.");
1458 return Some(decoded);
1459 }
1460 }
1461 Err(e) => {
1462 tracing::warn!("Failed to fetch visitor data via guide API: {}", e);
1463 }
1464 }
1465 None
1466 }
1467 fn extract_playlist_id(&self, identifier: &str) -> Option<String> {
1468 if identifier.contains("list=") {
1469 return Some(
1470 identifier
1471 .split("list=")
1472 .nth(1)
1473 .unwrap_or(identifier)
1474 .split('&')
1475 .next()
1476 .unwrap_or(identifier)
1477 .to_string(),
1478 );
1479 }
1480 None
1481 }
1482 fn extract_id(&self, identifier: &str) -> String {
1483 if identifier.contains("v=") {
1484 identifier
1485 .split("v=")
1486 .nth(1)
1487 .unwrap_or(identifier)
1488 .split('&')
1489 .next()
1490 .unwrap_or(identifier)
1491 .to_string()
1492 } else if identifier.contains("youtu.be/") {
1493 identifier
1494 .split("youtu.be/")
1495 .nth(1)
1496 .unwrap_or(identifier)
1497 .split('?')
1498 .next()
1499 .unwrap_or(identifier)
1500 .to_string()
1501 } else if identifier.contains("/live/") {
1502 identifier
1503 .split("/live/")
1504 .nth(1)
1505 .unwrap_or(identifier)
1506 .split('?')
1507 .next()
1508 .unwrap_or(identifier)
1509 .to_string()
1510 } else if identifier.contains("/shorts/") {
1511 identifier
1512 .split("/shorts/")
1513 .nth(1)
1514 .unwrap_or(identifier)
1515 .split('?')
1516 .next()
1517 .unwrap_or(identifier)
1518 .to_string()
1519 } else {
1520 identifier.to_string()
1521 }
1522 }
1523 fn prioritize_clients<'a>(
1524 &'a self,
1525 clients: &'a [Arc<dyn YouTubeClient>],
1526 prefer_music: bool,
1527 ) -> Vec<&'a Arc<dyn YouTubeClient>> {
1528 let is_music =
1529 |c: &&Arc<dyn YouTubeClient>| c.name().contains("Music") || c.name().contains("Remix");
1530 let mut ordered = Vec::with_capacity(clients.len());
1531 if prefer_music {
1532 ordered.extend(clients.iter().filter(|c| is_music(c)));
1533 ordered.extend(clients.iter().filter(|c| !is_music(c)));
1534 } else {
1535 ordered.extend(clients.iter().filter(|c| !is_music(c)));
1536 ordered.extend(clients.iter().filter(|c| is_music(c)));
1537 }
1538 ordered
1539 }
1540 fn fallback_clients<'a>(
1541 &'a self,
1542 tried: &[&Arc<dyn YouTubeClient>],
1543 prefer_music: bool,
1544 ) -> Vec<&'a Arc<dyn YouTubeClient>> {
1545 let tried_names: std::collections::HashSet<&str> = tried.iter().map(|c| c.name()).collect();
1546 let all_pools: &[&[Arc<dyn YouTubeClient>]] = &[
1547 &self.resolve_clients,
1548 &self.playback_clients,
1549 &self.search_clients,
1550 ];
1551 let mut seen = tried_names.clone();
1552 let mut fallback: Vec<&Arc<dyn YouTubeClient>> = Vec::new();
1553 for pool in all_pools {
1554 for client in pool.iter() {
1555 if seen.insert(client.name()) {
1556 fallback.push(client);
1557 }
1558 }
1559 }
1560 self.prioritize_clients_slice(&fallback, prefer_music)
1561 }
1562 fn prioritize_clients_slice<'a>(
1563 &self,
1564 clients: &[&'a Arc<dyn YouTubeClient>],
1565 prefer_music: bool,
1566 ) -> Vec<&'a Arc<dyn YouTubeClient>> {
1567 let is_music =
1568 |c: &&&Arc<dyn YouTubeClient>| c.name().contains("Music") || c.name().contains("Remix");
1569 let mut ordered = Vec::with_capacity(clients.len());
1570 if prefer_music {
1571 ordered.extend(clients.iter().filter(|c| is_music(c)).copied());
1572 ordered.extend(clients.iter().filter(|c| !is_music(c)).copied());
1573 } else {
1574 ordered.extend(clients.iter().filter(|c| !is_music(c)).copied());
1575 ordered.extend(clients.iter().filter(|c| is_music(c)).copied());
1576 }
1577 ordered
1578 }
1579 pub fn cipher_manager(&self) -> Arc<YouTubeCipherManager> {
1580 self.cipher_manager.clone()
1581 }
1582}
1583#[async_trait]
1584impl SourcePlugin for YouTubeSource {
1585 fn name(&self) -> &str {
1586 "youtube"
1587 }
1588 fn can_handle(&self, identifier: &str) -> bool {
1589 self.search_prefixes
1590 .iter()
1591 .any(|p| identifier.starts_with(p))
1592 || self.rec_prefixes.iter().any(|p| identifier.starts_with(p))
1593 || self.url_regex.is_match(identifier)
1594 }
1595 fn search_prefixes(&self) -> Vec<&str> {
1596 self.search_prefixes.iter().map(|s| s.as_str()).collect()
1597 }
1598 async fn load(
1599 &self,
1600 identifier: &str,
1601 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
1602 ) -> LoadResult {
1603 let visitor_data = self.visitor_data.read().await.clone();
1604 let context = if let Some(vd) = visitor_data {
1605 json!({ "visitorData": vd })
1606 } else {
1607 json!({})
1608 };
1609 if let Some(prefix) = self
1610 .search_prefixes
1611 .iter()
1612 .find(|p| identifier.starts_with(*p))
1613 {
1614 return self.handle_search(identifier, prefix, &context).await;
1615 }
1616 if let Some(prefix) = self
1617 .rec_prefixes
1618 .iter()
1619 .find(|p| identifier.starts_with(*p))
1620 {
1621 return self
1622 .handle_recommendations(identifier, prefix, &context)
1623 .await;
1624 }
1625 if self.url_regex.is_match(identifier) {
1626 return self.handle_url(identifier, &context).await;
1627 }
1628 LoadResult::Empty {}
1629 }
1630 async fn get_track(
1631 &self,
1632 identifier: &str,
1633 routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
1634 ) -> Option<BoxedTrack> {
1635 let visitor_data = self.visitor_data.read().await.clone();
1636 let id = self.extract_id(identifier);
1637 let is_music_url = identifier.contains("music.youtube.com");
1638 let clients_to_try = self.prioritize_clients(&self.playback_clients, is_music_url);
1639 let clients = clients_to_try.into_iter().cloned().collect();
1640 Some(Arc::new(track::YoutubeTrack {
1641 identifier: id,
1642 clients,
1643 oauth: self.oauth.clone(),
1644 cipher_manager: self.cipher_manager.clone(),
1645 visitor_data,
1646 local_addr: routeplanner.and_then(|rp| rp.get_address()),
1647 proxy: None,
1648 }))
1649 }
1650}
1651impl YouTubeSource {
1652 async fn handle_search(&self, identifier: &str, prefix: &str, context: &Value) -> LoadResult {
1653 let prefer_music = prefix == "ytmsearch:";
1654 let query = &identifier[prefix.len()..];
1655 let is_music =
1656 |c: &Arc<dyn YouTubeClient>| c.name().contains("Music") || c.name().contains("Remix");
1657 if prefer_music {
1658 let mut music_clients: Vec<&Arc<dyn YouTubeClient>> = Vec::new();
1659 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1660 tracing::debug!(
1661 "Search: prefer_music=true. search_clients count: {}",
1662 self.search_clients.len()
1663 );
1664 for c in &self.music_search_clients {
1665 if seen.insert(c.name()) {
1666 music_clients.push(c);
1667 }
1668 }
1669 for pool in [
1670 &self.search_clients[..],
1671 &self.resolve_clients[..],
1672 &self.playback_clients[..],
1673 ] {
1674 for c in pool {
1675 if is_music(c) && seen.insert(c.name()) {
1676 music_clients.push(c);
1677 }
1678 }
1679 }
1680 for client in &music_clients {
1681 if !client.can_handle_request(identifier) {
1682 continue;
1683 }
1684 tracing::debug!("Searching '{}' with {}", query, client.name());
1685 match client.search(query, context, self.oauth.clone()).await {
1686 Ok(tracks) if !tracks.is_empty() => return LoadResult::Search(tracks),
1687 Ok(_) => continue,
1688 Err(e) => tracing::warn!("Music search error with {}: {}", client.name(), e),
1689 }
1690 }
1691 tracing::debug!(
1692 "All music clients returned empty for '{}', falling back to regular search",
1693 query
1694 );
1695 }
1696 let primary: Vec<&Arc<dyn YouTubeClient>> = self
1697 .search_clients
1698 .iter()
1699 .filter(|c| !is_music(c))
1700 .collect();
1701 for client in &primary {
1702 if !client.can_handle_request(identifier) {
1703 continue;
1704 }
1705 tracing::debug!("Searching '{}' with {}", query, client.name());
1706 match client.search(query, context, self.oauth.clone()).await {
1707 Ok(tracks) if !tracks.is_empty() => return LoadResult::Search(tracks),
1708 Ok(_) => continue,
1709 Err(e) => tracing::warn!("Search error with {}: {}", client.name(), e),
1710 }
1711 }
1712 let mut seen_search: std::collections::HashSet<&str> =
1713 primary.iter().map(|c| c.name()).collect();
1714 let secondary_search: Vec<&Arc<dyn YouTubeClient>> = self
1715 .search_clients
1716 .iter()
1717 .filter(|c| is_music(c) && seen_search.insert(c.name()))
1718 .collect();
1719 for client in &secondary_search {
1720 if !client.can_handle_request(identifier) {
1721 continue;
1722 }
1723 tracing::debug!("Secondary search '{}' with {}", query, client.name());
1724 match client.search(query, context, self.oauth.clone()).await {
1725 Ok(tracks) if !tracks.is_empty() => return LoadResult::Search(tracks),
1726 Ok(_) => continue,
1727 Err(e) => tracing::warn!("Secondary search error with {}: {}", client.name(), e),
1728 }
1729 }
1730 let tried: Vec<&Arc<dyn YouTubeClient>> =
1731 primary.into_iter().chain(secondary_search).collect();
1732 let fallback = self.fallback_clients(&tried, false);
1733 if !fallback.is_empty() {
1734 tracing::debug!(
1735 "All search clients failed for '{}', trying fallback clients",
1736 query
1737 );
1738 }
1739 for client in fallback {
1740 if !client.can_handle_request(identifier) {
1741 continue;
1742 }
1743 tracing::debug!("Fallback search '{}' with {}", query, client.name());
1744 match client.search(query, context, self.oauth.clone()).await {
1745 Ok(tracks) if !tracks.is_empty() => return LoadResult::Search(tracks),
1746 Ok(_) => continue,
1747 Err(e) => tracing::warn!("Fallback search error with {}: {}", client.name(), e),
1748 }
1749 }
1750 LoadResult::Empty {}
1751 }
1752 async fn handle_recommendations(
1753 &self,
1754 identifier: &str,
1755 prefix: &str,
1756 context: &Value,
1757 ) -> LoadResult {
1758 let seed_id = &identifier[prefix.len()..];
1759 let playlist_id = format!("RD{}", seed_id);
1760 let clients = self.prioritize_clients(&self.resolve_clients, true);
1761 for client in clients {
1762 if !client.can_handle_request(&playlist_id) {
1763 continue;
1764 }
1765 match client
1766 .get_playlist(&playlist_id, context, self.oauth.clone())
1767 .await
1768 {
1769 Ok(Some((tracks, title))) => {
1770 let filtered: Vec<Track> = tracks
1771 .into_iter()
1772 .filter(|t| t.info.identifier != seed_id)
1773 .collect();
1774 return LoadResult::Playlist(PlaylistData {
1775 info: PlaylistInfo {
1776 name: format!("Recommendations: {}", title),
1777 selected_track: -1,
1778 },
1779 plugin_info: json!({
1780 "type": "recommendations",
1781 "totalTracks": filtered.len()
1782 }),
1783 tracks: filtered,
1784 });
1785 }
1786 _ => continue,
1787 }
1788 }
1789 LoadResult::Empty {}
1790 }
1791 async fn handle_url(&self, identifier: &str, context: &Value) -> LoadResult {
1792 let is_music_url = identifier.contains("music.youtube.com");
1793 if let Some(playlist_id) = self.extract_playlist_id(identifier) {
1794 let mut playlist_clients: Vec<&Arc<dyn YouTubeClient>> = Vec::new();
1795 for c in self.prioritize_clients(&self.resolve_clients, is_music_url) {
1796 if !playlist_clients.iter().any(|x| x.name() == c.name()) {
1797 playlist_clients.push(c);
1798 }
1799 }
1800 for c in self.fallback_clients(&playlist_clients, is_music_url) {
1801 if !playlist_clients.iter().any(|x| x.name() == c.name()) {
1802 playlist_clients.push(c);
1803 }
1804 }
1805 for client in &playlist_clients {
1806 if !client.can_handle_request(identifier) {
1807 continue;
1808 }
1809 tracing::debug!("Fetching playlist '{}' with {}", playlist_id, client.name());
1810 match client
1811 .get_playlist(&playlist_id, context, self.oauth.clone())
1812 .await
1813 {
1814 Ok(Some((tracks, title))) => {
1815 return LoadResult::Playlist(PlaylistData {
1816 info: PlaylistInfo {
1817 name: title,
1818 selected_track: -1,
1819 },
1820 plugin_info: json!({
1821 "type": "playlist",
1822 "url": format!("https://www.youtube.com/playlist?list={}", playlist_id),
1823 "artworkUrl": tracks.first().and_then(|t| t.info.artwork_url.clone()),
1824 "totalTracks": tracks.len()
1825 }),
1826 tracks,
1827 });
1828 }
1829 _ => continue,
1830 }
1831 }
1832 }
1833 let id = self.extract_id(identifier);
1834 let resolve_clients: Vec<&Arc<dyn YouTubeClient>> = self.resolve_clients.iter().collect();
1835 for client in &resolve_clients {
1836 if !client.can_handle_request(identifier) {
1837 continue;
1838 }
1839 tracing::debug!("Resolving track '{}' with {}", id, client.name());
1840 match client
1841 .get_track_info(&id, context, self.oauth.clone())
1842 .await
1843 {
1844 Ok(Some(mut track)) => {
1845 if is_music_url {
1846 track.info.uri = Some(format!("https://music.youtube.com/watch?v={}", id));
1847 }
1848 return LoadResult::Track(track);
1849 }
1850 Ok(None) => continue,
1851 Err(e) => tracing::warn!("Resolve error with {}: {}", client.name(), e),
1852 }
1853 }
1854 let fallback = self.fallback_clients(&resolve_clients, false);
1855 if !fallback.is_empty() {
1856 tracing::debug!(
1857 "All resolve clients failed for '{}', trying {} fallback client(s)",
1858 id,
1859 fallback.len()
1860 );
1861 }
1862 for client in fallback {
1863 if !client.can_handle_request(identifier) {
1864 continue;
1865 }
1866 tracing::debug!("Fallback resolve '{}' with {}", id, client.name());
1867 if let Ok(Some(mut track)) = client
1868 .get_track_info(&id, context, self.oauth.clone())
1869 .await
1870 {
1871 if is_music_url {
1872 track.info.uri = Some(format!("https://music.youtube.com/watch?v={}", id));
1873 }
1874 return LoadResult::Track(track);
1875 }
1876 }
1877 LoadResult::Empty {}
1878 }
1879}