feat: add cookies.txt parser, add cookie auth + history cmds to CLI

This commit is contained in:
ThetaDev 2025-01-05 02:52:30 +01:00
parent 3c95b52cea
commit cf498e4a8f
No known key found for this signature in database
GPG key ID: E319D3C5148D65B6
3 changed files with 328 additions and 43 deletions

View file

@ -201,12 +201,41 @@ enum Commands {
#[clap(short, long)] #[clap(short, long)]
music: Option<MusicSearchCategory>, music: Option<MusicSearchCategory>,
}, },
/// Get your playback history
History {
/// Output format
#[clap(short, long, value_parser)]
format: Option<Format>,
/// 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<String>,
},
/// Get a YouTube visitor data cookie /// Get a YouTube visitor data cookie
Vdata, Vdata,
/// Log in using your Google account /// 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<PathBuf>,
},
/// Log out from your Google account /// 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)] #[derive(Default, Copy, Clone, ValueEnum)]
@ -363,19 +392,23 @@ fn print_data<T: Serialize>(data: &T, format: Format, pretty: bool) {
fn print_entities(items: &[impl YtEntity], with_type: bool) { fn print_entities(items: &[impl YtEntity], with_type: bool) {
for e in items { for e in items {
if with_type { print_entity(e, 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_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]) { fn print_tracks(tracks: &[TrackItem]) {
for t in tracks { for t in tracks {
if let Some(n) = t.track_nr { if let Some(n) = t.track_nr {
@ -1292,28 +1325,136 @@ async fn run() -> anyhow::Result<()> {
print_music_search(&res, format, pretty, false); 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 => { Commands::Vdata => {
let vd = rp.query().get_visitor_data().await?; let vd = rp.query().get_visitor_data().await?;
println!("{vd}"); println!("{vd}");
} }
Commands::Login => { Commands::Login {
match rp.user_auth_check_login().await { cookie,
Ok(_) => {} cookies_txt,
Err(rustypipe::error::Error::Auth(_)) => { } => {
let device_code = rp.user_auth_get_code().await?; if cookie || cookies_txt.is_some() {
println!( match rp.user_auth_check_cookie().await {
"Open {} and enter the following code:", Ok(_) => {
device_code.verification_url println!("Already logged in.");
); }
anstream::println!("{}", device_code.user_code.blue()); Err(rustypipe::error::Error::Auth(_)) => {
rp.user_auth_wait_for_login(&device_code).await?; 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 => { Commands::Logout { cookie } => {
rp.user_auth_logout().await?; if cookie {
rp.user_auth_remove_cookie().await?;
} else {
rp.user_auth_logout().await?;
}
anstream::println!("{}", "Logged out.".red()); anstream::println!("{}", "Logged out.".red());
} }
}; };

View file

@ -1463,23 +1463,80 @@ impl RustyPipe {
} }
} }
/// Get a copy of the authentication cookie from the cache
fn user_auth_cookie(&self) -> Result<AuthCookie, Error> {
self.inner
.cache
.auth_cookie
.read()
.unwrap()
.clone()
.ok_or(Error::Auth(AuthError::NoLogin))
}
/// Set the user authentication cookie /// Set the user authentication cookie
/// ///
/// The cookie is used for authenticated requests with browser-based clients /// The cookie is used for authenticated requests with browser-based clients
/// (Desktop, DesktopMusic, Mobile). /// (Desktop, DesktopMusic, Mobile).
/// ///
/// **Note:** YouTube rotates cookies every few minutes when using the web applications. /// **Note:** YouTube rotates cookies every few minutes when using the web application.
/// So you should not use the session you obtained cookies from afterwards or it will /// Do not use the session you obtained cookies from afterwards or it will
/// become invalid. /// become invalid.
/// ///
/// I recommend to log in using Incognito mode, get the cookies from the devtools /// I recommend to log in using Incognito mode, get the cookies from the devtools
/// and then close the page. /// and then close the page.
pub async fn set_auth_cookie<S: Into<String>>(&self, cookie: S) -> Result<(), Error> { pub async fn user_auth_set_cookie<S: Into<String>>(&self, cookie: S) -> Result<(), Error> {
let mut auth_cookie = AuthCookie::new(cookie.into()); 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?; 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(); /// Parse the user authentication cookie from a Netscape HTTP Cookie File
*c = Some(auth_cookie); ///
/// 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(()) Ok(())
} }
@ -1488,7 +1545,7 @@ impl RustyPipe {
/// ///
/// The header values are included in the ytcfg object which is embedded in the html code. /// 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> { 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_sync_id = Regex::new(r#""datasyncId":"([\w|]+?)""#).unwrap();
let re_session_index = Regex::new(r#""SESSION_INDEX":"([\d]+?)""#).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?; let html = self.http_request_txt(&req).await?;
if !re_session_id.is_match(&html) { 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)); return Err(Error::Auth(AuthError::NoLogin));
} }
@ -1904,15 +1962,7 @@ impl RustyPipeQuery {
if self.opts.auth == Some(true) { if self.opts.auth == Some(true) {
if ctype.is_web() { if ctype.is_web() {
let auth_cookie = self let auth_cookie = self.client.user_auth_cookie()?;
.client
.inner
.cache
.auth_cookie
.read()
.unwrap()
.clone()
.ok_or(Error::Auth(AuthError::NoLogin))?;
if let Some(auth_header) = Self::sapisidhash_header(&auth_cookie.cookie, ctype) { if let Some(auth_header) = Self::sapisidhash_header(&auth_cookie.cookie, ctype) {
r = r.header(header::AUTHORIZATION, auth_header); r = r.header(header::AUTHORIZATION, auth_header);

View file

@ -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<String, Error> {
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<Option<(&'a str, &'a str)>, 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)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use std::{fs::File, io::BufReader, path::PathBuf}; use std::{fs::File, io::BufReader, path::PathBuf};
@ -810,4 +873,35 @@ pub(crate) mod tests {
Err(0) 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"
);
}
} }