feat: add cookies.txt parser, add cookie auth + history cmds to CLI
This commit is contained in:
parent
3c95b52cea
commit
cf498e4a8f
3 changed files with 328 additions and 43 deletions
195
cli/src/main.rs
195
cli/src/main.rs
|
|
@ -201,12 +201,41 @@ enum Commands {
|
|||
#[clap(short, long)]
|
||||
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
|
||||
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<PathBuf>,
|
||||
},
|
||||
/// 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<T: Serialize>(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());
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
///
|
||||
/// 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<S: Into<String>>(&self, cookie: S) -> Result<(), Error> {
|
||||
let mut auth_cookie = AuthCookie::new(cookie.into());
|
||||
pub async fn user_auth_set_cookie<S: Into<String>>(&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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue