From cf498e4a8f9318b0197bc3f0cbaf7043c53adb9d Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 5 Jan 2025 02:52:30 +0100 Subject: [PATCH] feat: add cookies.txt parser, add cookie auth + history cmds to CLI --- cli/src/main.rs | 195 +++++++++++++++++++++++++++++++++++++++------- src/client/mod.rs | 82 +++++++++++++++---- src/util/mod.rs | 94 ++++++++++++++++++++++ 3 files changed, 328 insertions(+), 43 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 0a1f568..350055f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -201,12 +201,41 @@ enum Commands { #[clap(short, long)] music: Option, }, + /// Get your playback history + History { + /// Output format + #[clap(short, long, value_parser)] + format: Option, + /// Pretty-print output + #[clap(long)] + pretty: bool, + /// Limit the number of items to fetch + #[clap(short, long, default_value_t = 20)] + limit: usize, + /// Use YouTube Music + #[clap(short, long)] + music: bool, + /// Search YouTube playback history + #[clap(long)] + search: Option, + }, /// Get a YouTube visitor data cookie Vdata, /// Log in using your Google account - Login, + Login { + /// Log in using YouTube cookies (otherwise OAuth is used) + #[clap(long)] + cookie: bool, + /// Path to cookie.txt + #[clap(long)] + cookies_txt: Option, + }, /// Log out from your Google account - Logout, + Logout { + /// Remove stored YouTube cookies (otherwise OAuth is used) + #[clap(long)] + cookie: bool, + }, } #[derive(Default, Copy, Clone, ValueEnum)] @@ -363,19 +392,23 @@ fn print_data(data: &T, format: Format, pretty: bool) { fn print_entities(items: &[impl YtEntity], with_type: bool) { for e in items { - if with_type { - if let Some(t) = e.music_item_type() { - anstream::print!("{: >8} ", format!("{t:?}").dimmed()); - } - } - anstream::print!("[{}] {}", e.id(), e.name().bold()); - if let Some(n) = e.channel_name() { - anstream::print!(" - {}", n.cyan()); - } - println!(); + print_entity(e, with_type); } } +fn print_entity(e: &impl YtEntity, with_type: bool) { + if with_type { + if let Some(t) = e.music_item_type() { + anstream::print!("{: >8} ", format!("{t:?}").dimmed()); + } + } + anstream::print!("[{}] {}", e.id(), e.name().bold()); + if let Some(n) = e.channel_name() { + anstream::print!(" - {}", n.cyan()); + } + println!(); +} + fn print_tracks(tracks: &[TrackItem]) { for t in tracks { if let Some(n) = t.track_nr { @@ -1292,28 +1325,136 @@ async fn run() -> anyhow::Result<()> { print_music_search(&res, format, pretty, false); } }, + Commands::History { + format, + pretty, + limit, + music, + search, + } => { + if music { + let mut history = rp.query().music_history().await?; + history.extend_limit(rp.query(), limit).await?; + + match format { + Some(format) => print_data(&history, format, pretty), + None => { + anstream::println!("{}", "[Music history]".on_green().black()); + + let mut last_date = None; + for item in history.items { + if last_date != item.playback_date { + println!(); + if let Some(dt) = item.playback_date { + anstream::println!("{}", dt.green().underline()); + } + last_date = item.playback_date; + } + + let t = item.item; + anstream::print!("[{}] {} - ", t.id, t.name.bold()); + print_artists(&t.artists); + print_duration(t.duration); + println!(); + } + } + } + } else { + let mut history = match search { + Some(query) => rp.query().history_search(query).await?, + None => rp.query().history().await?, + }; + history.extend_limit(rp.query(), limit).await?; + + match format { + Some(format) => print_data(&history, format, pretty), + None => { + anstream::println!("{}", "[History]".on_green().black()); + + let mut last_date = None; + for item in history.items { + if last_date != item.playback_date { + println!(); + if let Some(dt) = item.playback_date { + anstream::println!("{}", dt.green().underline()); + } + last_date = item.playback_date; + } + print_entity(&item.item, false); + } + } + } + } + } Commands::Vdata => { let vd = rp.query().get_visitor_data().await?; println!("{vd}"); } - Commands::Login => { - match rp.user_auth_check_login().await { - Ok(_) => {} - Err(rustypipe::error::Error::Auth(_)) => { - let device_code = rp.user_auth_get_code().await?; - println!( - "Open {} and enter the following code:", - device_code.verification_url - ); - anstream::println!("{}", device_code.user_code.blue()); - rp.user_auth_wait_for_login(&device_code).await?; + Commands::Login { + cookie, + cookies_txt, + } => { + if cookie || cookies_txt.is_some() { + match rp.user_auth_check_cookie().await { + Ok(_) => { + println!("Already logged in."); + } + Err(rustypipe::error::Error::Auth(_)) => { + let cookie_raw = if let Some(cookie_txt) = cookies_txt { + std::fs::read_to_string(cookie_txt)? + } else { + println!("Enter cookie header or cookies.txt:"); + + // Read until 2 consecutive newlines + let mut line = String::new(); + let mut last_len = 0; + let mut stop = 0; + while stop < 2 { + std::io::stdin().read_line(&mut line)?; + if line.len() <= last_len + 1 { + stop += 1; + } else { + stop = 0; + } + last_len = line.len(); + } + line + }; + + if cookie_raw.contains('\t') { + rp.user_auth_set_cookie_txt(&cookie_raw).await?; + } else { + rp.user_auth_set_cookie(cookie_raw.trim()).await?; + } + anstream::println!("{}", "Logged in.".green()); + } + Err(e) => return Err(e.into()), + } + } else { + match rp.user_auth_check_login().await { + Ok(_) => { + println!("Already logged in."); + } + Err(rustypipe::error::Error::Auth(_)) => { + let device_code = rp.user_auth_get_code().await?; + println!( + "Open {} and enter the following code:", + device_code.verification_url + ); + anstream::println!("{}", device_code.user_code.blue()); + rp.user_auth_wait_for_login(&device_code).await?; + anstream::println!("{}", "Logged in.".green()); + } + Err(e) => return Err(e.into()), } - Err(e) => return Err(e.into()), } - anstream::println!("{}", "Logged in.".green()); } - Commands::Logout => { - rp.user_auth_logout().await?; + Commands::Logout { cookie } => { + if cookie { + rp.user_auth_remove_cookie().await?; + } else { + rp.user_auth_logout().await?; + } anstream::println!("{}", "Logged out.".red()); } }; diff --git a/src/client/mod.rs b/src/client/mod.rs index 0c6130f..0f41bca 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1463,23 +1463,80 @@ impl RustyPipe { } } + /// Get a copy of the authentication cookie from the cache + fn user_auth_cookie(&self) -> Result { + self.inner + .cache + .auth_cookie + .read() + .unwrap() + .clone() + .ok_or(Error::Auth(AuthError::NoLogin)) + } + /// Set the user authentication cookie /// /// The cookie is used for authenticated requests with browser-based clients /// (Desktop, DesktopMusic, Mobile). /// - /// **Note:** YouTube rotates cookies every few minutes when using the web applications. - /// So you should not use the session you obtained cookies from afterwards or it will + /// **Note:** YouTube rotates cookies every few minutes when using the web application. + /// Do not use the session you obtained cookies from afterwards or it will /// become invalid. /// /// I recommend to log in using Incognito mode, get the cookies from the devtools /// and then close the page. - pub async fn set_auth_cookie>(&self, cookie: S) -> Result<(), Error> { - let mut auth_cookie = AuthCookie::new(cookie.into()); + pub async fn user_auth_set_cookie>(&self, cookie: S) -> Result<(), Error> { + let cookie = cookie.into(); + if cookie.is_empty() { + return Err(Error::Auth(AuthError::NoLogin)); + } + let mut auth_cookie = AuthCookie::new(cookie); self.extract_session_headers(&mut auth_cookie).await?; + { + let mut c = self.inner.cache.auth_cookie.write().unwrap(); + *c = Some(auth_cookie); + } + self.store_cache().await; + Ok(()) + } - let mut c = self.inner.cache.auth_cookie.write().unwrap(); - *c = Some(auth_cookie); + /// Parse the user authentication cookie from a Netscape HTTP Cookie File + /// + /// The cookie is used for authenticated requests with browser-based clients + /// (Desktop, DesktopMusic, Mobile). + /// + /// cookie.txt files can be extracted using browser plugins like + /// "Get cookies.txt LOCALLY" ([Firefox](https://addons.mozilla.org/de/firefox/addon/get-cookies-txt-locally/)) + /// ([Chromium](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)). + /// + /// **Note:** YouTube rotates cookies every few minutes when using the web application. + /// Do not use the session you obtained cookies from afterwards or it will + /// become invalid. + /// + /// I recommend to log in using Incognito mode, obtain the cookies and then close the page. + pub async fn user_auth_set_cookie_txt(&self, cookies: &str) -> Result<(), Error> { + let cookie = util::parse_netscape_cookies(cookies, ".youtube.com")?; + self.user_auth_set_cookie(cookie).await + } + + /// Remove the user authentication cookie from cache storage + pub async fn user_auth_remove_cookie(&self) -> Result<(), Error> { + { + let mut cookie = self.inner.cache.auth_cookie.write().unwrap(); + if cookie.is_none() { + return Err(Error::Auth(AuthError::NoLogin)); + } + *cookie = None; + } + self.store_cache().await; + Ok(()) + } + + /// Attempt to fetch the YouTube website with login cookies to check if the user is successfully logged in + /// and the session is still valid. + pub async fn user_auth_check_cookie(&self) -> Result<(), Error> { + let mut cookie = self.user_auth_cookie()?; + self.extract_session_headers(&mut cookie).await?; Ok(()) } @@ -1488,7 +1545,7 @@ impl RustyPipe { /// /// The header values are included in the ytcfg object which is embedded in the html code. async fn extract_session_headers(&self, auth_cookie: &mut AuthCookie) -> Result<(), Error> { - let re_session_id = Regex::new(r#"""USER_SESSION_ID"":"[\d]+?""#).unwrap(); + let re_session_id = Regex::new(r#""USER_SESSION_ID":"[\d]+?""#).unwrap(); let re_sync_id = Regex::new(r#""datasyncId":"([\w|]+?)""#).unwrap(); let re_session_index = Regex::new(r#""SESSION_INDEX":"([\d]+?)""#).unwrap(); @@ -1501,6 +1558,7 @@ impl RustyPipe { let html = self.http_request_txt(&req).await?; if !re_session_id.is_match(&html) { + tracing::debug!("session check failed: USER_SESSION_ID not found in reponse"); return Err(Error::Auth(AuthError::NoLogin)); } @@ -1904,15 +1962,7 @@ impl RustyPipeQuery { if self.opts.auth == Some(true) { if ctype.is_web() { - let auth_cookie = self - .client - .inner - .cache - .auth_cookie - .read() - .unwrap() - .clone() - .ok_or(Error::Auth(AuthError::NoLogin))?; + let auth_cookie = self.client.user_auth_cookie()?; if let Some(auth_header) = Self::sapisidhash_header(&auth_cookie.cookie, ctype) { r = r.header(header::AUTHORIZATION, auth_header); diff --git a/src/util/mod.rs b/src/util/mod.rs index 4241262..5e53ba7 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -608,6 +608,69 @@ pub fn map_internal_playlist_err(e: Error) -> Error { } } +/// Parse cookies from a Netscape HTTP Cookie File and return a cookie header value +pub fn parse_netscape_cookies(cookies: &str, filter_domain: &str) -> Result { + let mut res = cookies + .lines() + .enumerate() + .map(|(line_n, line)| parse_netscape_cookie_line(line, line_n + 1, filter_domain)) + .try_fold(String::new(), |mut acc, itm| { + if let Some((k, v)) = itm? { + acc += k; + acc.push('='); + acc += v; + acc += "; "; + } + Ok::<_, Error>(acc) + })?; + + if !res.is_empty() { + res.truncate(res.len() - 2); + } + Ok(res) +} + +fn parse_netscape_cookie_line<'a>( + line: &'a str, + line_n: usize, + filter_domain: &str, +) -> Result, Error> { + let mut line = line.trim(); + + if let Some(s) = line.strip_prefix("#HttpOnly_") { + line = s; + } else if line.is_empty() || line.starts_with('#') { + return Ok(None); + } + + let mkerr = || Error::Other(format!("line {line_n}: too few fields, expected 7").into()); + + let mut cols = line.split('\t'); + let domain = cols.next().ok_or_else(mkerr)?; + if domain != filter_domain { + return Ok(None); + } + let include_subdomains = cols.next().ok_or_else(mkerr)?.to_ascii_lowercase() == "true"; + if !include_subdomains { + return Ok(None); + } + let path = cols.next().ok_or_else(mkerr)?; + if path != "/" { + return Ok(None); + } + // skip secure, expire + let name = cols.nth(2).ok_or_else(mkerr)?; + let value = cols.next().ok_or_else(mkerr)?; + + if cols.next().is_some() { + return Err(Error::Other( + format!("line {line_n}: too many fields, expected 7").into(), + )); + } + + Ok(Some((name, value))) +} + #[cfg(test)] pub(crate) mod tests { use std::{fs::File, io::BufReader, path::PathBuf}; @@ -810,4 +873,35 @@ pub(crate) mod tests { Err(0) ); } + + #[test] + fn t_parse_netscape_cookies() { + let cookies = r#"# Netscape HTTP Cookie File +# http://curl.haxx.se/rfc/cookie_spec.html +# This is a generated file! Do not edit. + +# Domain Subdomain Path Secure Expire Name Value +.www.youtube.com TRUE / FALSE 1769704561 yt-dev.storage-integrity true +.youtube.com TRUE / TRUE 1763481937 SOCS Abcdefg +.youtube.com TRUE / TRUE 1744905937 __Secure-BUCKET IE7E +"#; + let filter_domain = ".youtube.com"; + let parsed = parse_netscape_cookies(cookies, filter_domain).unwrap(); + assert_eq!(&parsed, "SOCS=Abcdefg; __Secure-BUCKET=IE7E"); + + let cookies_too_few_cols = r#".youtube.com TRUE / TRUE 1763481937 SOCS"#; + let cookies_too_many_cols = r#".youtube.com TRUE / TRUE 1763481937 SOCS Abcdefg foo"#; + assert_eq!( + parse_netscape_cookies(cookies_too_few_cols, filter_domain) + .unwrap_err() + .to_string(), + "error: line 1: too few fields, expected 7" + ); + assert_eq!( + parse_netscape_cookies(cookies_too_many_cols, filter_domain) + .unwrap_err() + .to_string(), + "error: line 1: too many fields, expected 7" + ); + } }