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)]
|
#[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());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in a new issue