Skip to main content

lavende_core/sources/youtube/
mod.rs

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}