use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime};
use crate::i18n::Lang;
const ORIGIN: &str = "https://www.youtube.com";
const COOKIES_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
#[cfg(not(any(windows, target_os = "macos")))]
const CHROMIUM: &[(&str, &[&str])] = &[
(
"brave",
&[".config/BraveSoftware/Brave-Browser", ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"],
),
("chrome", &[".config/google-chrome", ".var/app/com.google.Chrome/config/google-chrome"]),
(
"chromium",
&[".config/chromium", "snap/chromium/common/chromium", ".var/app/org.chromium.Chromium/config/chromium"],
),
("vivaldi", &[".config/vivaldi", ".var/app/com.vivaldi.Vivaldi/config/vivaldi"]),
("edge", &[".config/microsoft-edge", ".var/app/com.microsoft.Edge/config/microsoft-edge"]),
("opera", &[".config/opera", ".var/app/com.opera.Opera/config/opera"]),
];
#[cfg(windows)]
const CHROMIUM: &[(&str, &[&str])] = &[
("brave", &["AppData/Local/BraveSoftware/Brave-Browser/User Data"]),
("chrome", &["AppData/Local/Google/Chrome/User Data"]),
("chromium", &["AppData/Local/Chromium/User Data"]),
("vivaldi", &["AppData/Local/Vivaldi/User Data"]),
("edge", &["AppData/Local/Microsoft/Edge/User Data"]),
("opera", &["AppData/Roaming/Opera Software/Opera Stable"]),
];
#[cfg(target_os = "macos")]
const CHROMIUM: &[(&str, &[&str])] = &[
("brave", &["Library/Application Support/BraveSoftware/Brave-Browser"]),
("chrome", &["Library/Application Support/Google/Chrome"]),
("chromium", &["Library/Application Support/Chromium"]),
("vivaldi", &["Library/Application Support/Vivaldi"]),
("edge", &["Library/Application Support/Microsoft Edge"]),
("opera", &["Library/Application Support/com.operasoftware.Opera"]),
];
#[cfg(not(any(windows, target_os = "macos")))]
const FIREFOX: &[&str] = &[
".mozilla/firefox",
".config/mozilla/firefox",
"snap/firefox/common/.mozilla/firefox",
".var/app/org.mozilla.firefox/.mozilla/firefox",
];
#[cfg(windows)]
const FIREFOX: &[&str] = &["AppData/Roaming/Mozilla/Firefox/Profiles"];
#[cfg(target_os = "macos")]
const FIREFOX: &[&str] = &["Library/Application Support/Firefox/Profiles"];
pub fn scan_browsers(home: &Path, keyring: &str) -> Vec<String> {
let keyring = if keyring.is_empty() { String::new() } else { format!("+{keyring}") };
let mut found = Vec::new();
for (name, dirs) in CHROMIUM {
for (i, dir) in dirs.iter().enumerate() {
for profile in subdirs(&home.join(dir)) {
let is_profile = profile == "Default" || profile.starts_with("Profile ");
let base = home.join(dir).join(&profile);
if is_profile && (base.join("Cookies").exists() || base.join("Network/Cookies").exists()) {
let profile = if i == 0 { profile } else { base.display().to_string() };
found.push(format!("{name}{keyring}:{profile}"));
}
}
}
}
for (i, dir) in FIREFOX.iter().enumerate() {
for profile in subdirs(&home.join(dir)) {
let base = home.join(dir).join(&profile);
if base.join("cookies.sqlite").exists() {
let profile = if i == 0 { profile } else { base.display().to_string() };
found.push(format!("firefox:{profile}"));
}
}
}
found
}
pub fn resolve_keyring(configured: &str) -> String {
if configured.is_empty() { detect_keyring() } else { configured.to_string() }
}
#[cfg(not(any(windows, target_os = "macos")))]
pub fn detect_keyring() -> String {
let running = running_programs();
let is_running = |name: &str| running.iter().any(|p| p == name);
if is_running("kwalletd6") {
"KWALLET6".into()
} else if is_running("kwalletd5") {
"KWALLET5".into()
} else if is_running("gnome-keyring-d") || is_running("keepassxc") || secret_service_available() {
"GNOMEKEYRING".into()
} else {
String::new()
}
}
#[cfg(any(windows, target_os = "macos"))]
pub fn detect_keyring() -> String {
String::new()
}
#[cfg(not(any(windows, target_os = "macos")))]
fn running_programs() -> Vec<String> {
std::fs::read_dir("/proc")
.into_iter()
.flatten()
.flatten()
.filter_map(|e| std::fs::read_to_string(e.path().join("comm")).ok())
.map(|comm| comm.trim_end().to_string())
.collect()
}
#[cfg(not(any(windows, target_os = "macos")))]
fn secret_service_available() -> bool {
Command::new("dbus-send")
.args([
"--session",
"--print-reply",
"--dest=org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus.ListActivatableNames",
])
.output()
.is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("\"org.freedesktop.secrets\""))
}
pub fn with_keyring(spec: &str, keyring: &str) -> String {
let (browser, profile) = match spec.split_once(':') {
Some((browser, profile)) => (browser, Some(profile)),
None => (spec, None),
};
if keyring.is_empty() || browser.contains('+') || !CHROMIUM.iter().any(|(name, _)| *name == browser) {
return spec.to_string();
}
match profile {
Some(profile) => format!("{browser}+{keyring}:{profile}"),
None => format!("{browser}+{keyring}"),
}
}
pub fn label(spec: &str) -> String {
let (browser, profile) = spec.split_once(':').unwrap_or((spec, ""));
let browser = browser.split('+').next().unwrap_or(browser);
let place = if profile.contains("/snap/") {
" (snap)"
} else if profile.contains("/.var/app/") {
" (flatpak)"
} else {
""
};
let profile = profile.rsplit('/').next().unwrap_or(profile);
if profile.is_empty() { format!("{browser}{place}") } else { format!("{browser}{place} · {profile}") }
}
fn subdirs(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
pub fn cookies_path(cache_dir: &Path, spec: &str) -> PathBuf {
let safe: String = spec.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
cache_dir.join(format!("cookies-{safe}.txt"))
}
pub fn refresh_cookies(spec: &str, path: &Path, force: bool, lang: Lang) -> Result<(), String> {
if !force && is_fresh(path, COOKIES_MAX_AGE) {
return Ok(());
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let output = Command::new("yt-dlp")
.args(["-q", "--skip-download", "--flat-playlist", "-I", "0", "--cookies-from-browser", spec, "--cookies"])
.arg(path)
.arg(":ytsubs")
.output()
.map_err(|e| format!("{}: {e}", lang.pick("no se pudo ejecutar yt-dlp", "couldn't run yt-dlp")))?;
let stderr = String::from_utf8_lossy(&output.stderr);
let jar = std::fs::read_to_string(path).ok();
let error = cookie_error(spec, &stderr, lang)
.or_else(|| jar.as_deref().is_some_and(|text| !has_login(text)).then(|| no_login(spec, lang)));
if let Some(error) = error {
let _ = std::fs::remove_file(path);
Err(error)
} else if jar.is_some() {
Ok(())
} else {
let detail = stderr.lines().last().unwrap_or(lang.pick("sin detalle", "no details"));
Err(format!("{} {spec}: {detail}", lang.pick("no se pudo leer la sesión de", "couldn't read the login of")))
}
}
pub fn has_login(text: &str) -> bool {
authorization(&parse_cookies(text), 0).is_some()
}
fn no_login(spec: &str, lang: Lang) -> String {
let (label, browser) = (label(spec), browser_name(spec));
match lang {
Lang::Es => format!(
"{label} no tiene sesión de YouTube: entrá a youtube.com en {browser}, iniciá sesión y navegá un poco; \
después volvé y usá Configuración → Releer la sesión"
),
Lang::En => format!(
"{label} has no YouTube login: open youtube.com in {browser}, log in and browse a little; \
then come back and use Settings → Read the login again"
),
}
}
fn cookie_error(spec: &str, stderr: &str, lang: Lang) -> Option<String> {
let (label, browser) = (label(spec), browser_name(spec));
let error = if stderr.contains("secretstorage not available") {
lang.pick(
"a yt-dlp le falta el módulo secretstorage para abrir el llavero: \
Configuración → Revisar el sistema te da el comando para instalarlo",
"yt-dlp lacks the secretstorage module to open the keyring: \
Settings → Check the system gives the command to install it",
)
.to_string()
} else if stderr.contains("kwallet-query command not found") {
lang.pick(
"falta kwallet-query para abrir KWallet: instalalo (viene con el paquete kwallet de tu distribución)",
"kwallet-query is missing to open KWallet: install it (it comes with your distribution's kwallet package)",
)
.to_string()
} else if stderr.contains("failed to read from keyring") || stderr.contains("kwallet-query failed") {
match lang {
Lang::Es => format!(
"no encontré la clave de {browser} en el llavero: desbloquealo, abrí {browser} una vez \
y volvé a leer la sesión"
),
Lang::En => {
format!("{browser}'s key isn't in the keyring: unlock it, open {browser} once and read the login again")
}
}
} else if stderr.contains("Could not copy Chrome cookie database") {
match lang {
Lang::Es => format!(
"no pude copiar las cookies de {browser}: cerralo y volvé a leer la sesión, o usá Firefox o un cookies.txt"
),
Lang::En => format!(
"couldn't copy {browser}'s cookies: close it and read the login again, or use Firefox or a cookies.txt"
),
}
} else if stderr.contains("cannot decrypt") {
match lang {
Lang::Es => format!(
"yt-dlp no pudo descifrar las cookies de {label}: poné keyring = \"GNOMEKEYRING\" o \"KWALLET6\" \
en config.toml y volvé a elegir el navegador"
),
Lang::En => format!(
"yt-dlp couldn't decrypt {label}'s cookies: set keyring = \"GNOMEKEYRING\" or \"KWALLET6\" \
in config.toml and pick the browser again"
),
}
} else {
return None;
};
Some(error)
}
fn browser_name(spec: &str) -> &str {
spec.split([':', '+']).next().unwrap_or(spec)
}
pub fn is_fresh(path: &Path, max_age: Duration) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.is_ok_and(|t| SystemTime::now().duration_since(t).is_ok_and(|age| age < max_age))
}
pub fn parse_cookies(text: &str) -> HashMap<String, String> {
text.lines()
.filter_map(|line| {
let line = line.strip_prefix("#HttpOnly_").unwrap_or(line);
if line.starts_with('#') {
return None;
}
let fields: Vec<&str> = line.split('\t').collect();
match fields.as_slice() {
[domain, _, _, _, _, name, value] if domain.ends_with("youtube.com") => {
Some((name.to_string(), value.to_string()))
}
_ => None,
}
})
.collect()
}
pub fn authorization(cookies: &HashMap<String, String>, timestamp: u64) -> Option<String> {
let third_party = cookies.get("__Secure-3PAPISID");
let sapisid = cookies.get("SAPISID").or(third_party)?;
let mut header = format!("SAPISIDHASH {}", sapisidhash(timestamp, sapisid));
if let Some(value) = third_party {
header.push_str(&format!(" SAPISID3PHASH {}", sapisidhash(timestamp, value)));
}
Some(header)
}
fn sapisidhash(timestamp: u64, sapisid: &str) -> String {
let digest = sha1_smol::Sha1::from(format!("{timestamp} {sapisid} {ORIGIN}")).digest();
format!("{timestamp}_{digest}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::temp_dir;
#[test]
fn sapisidhash_matches_youtube_web_client() {
assert_eq!(sapisidhash(1_700_000_000, "abcSAPISID"), "1700000000_b4d59c573190b5106be035dd4b3e304f7edd9c46");
}
#[test]
fn authorization_needs_a_sapisid() {
assert_eq!(authorization(&HashMap::new(), 1), None);
let only_3p = HashMap::from([("__Secure-3PAPISID".to_string(), "abcSAPISID".to_string())]);
let header = authorization(&only_3p, 1_700_000_000).unwrap();
assert_eq!(
header,
"SAPISIDHASH 1700000000_b4d59c573190b5106be035dd4b3e304f7edd9c46 \
SAPISID3PHASH 1700000000_b4d59c573190b5106be035dd4b3e304f7edd9c46"
);
}
#[test]
fn parses_youtube_cookies_only() {
let text = "# Netscape HTTP Cookie File\n\
.youtube.com\tTRUE\t/\tTRUE\t0\tSAPISID\tabc\n\
#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tsecret\n\
.google.com\tTRUE\t/\tTRUE\t0\tNID\tnope\n\
broken line\n";
let cookies = parse_cookies(text);
assert_eq!(cookies.len(), 2);
assert_eq!(cookies["SAPISID"], "abc");
assert_eq!(cookies["SID"], "secret");
}
#[cfg(not(any(windows, target_os = "macos")))]
#[test]
fn scan_finds_chromium_and_firefox_profiles() {
let home = temp_dir("scan");
let touch = |p: &str| {
let path = home.join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
};
touch(".config/BraveSoftware/Brave-Browser/Default/Cookies");
touch(".config/BraveSoftware/Brave-Browser/Profile 1/Network/Cookies");
touch(".config/BraveSoftware/Brave-Browser/System Profile/Cookies");
touch(".config/google-chrome/Default/Preferences");
touch(".mozilla/firefox/ab12.default-release/cookies.sqlite");
touch("snap/firefox/common/.mozilla/firefox/cd34.default/cookies.sqlite");
touch(".var/app/com.google.Chrome/config/google-chrome/Default/Cookies");
let snap = home.join("snap/firefox/common/.mozilla/firefox/cd34.default").display().to_string();
let flatpak = home.join(".var/app/com.google.Chrome/config/google-chrome/Default").display().to_string();
assert_eq!(
scan_browsers(&home, "GNOMEKEYRING"),
[
"brave+GNOMEKEYRING:Default".to_string(),
"brave+GNOMEKEYRING:Profile 1".into(),
format!("chrome+GNOMEKEYRING:{flatpak}"),
"firefox:ab12.default-release".into(),
format!("firefox:{snap}"),
]
);
assert_eq!(scan_browsers(&home, "")[0], "brave:Default");
assert_eq!(label(&format!("firefox:{snap}")), "firefox (snap) · cd34.default");
assert_eq!(label(&format!("chrome+GNOMEKEYRING:{flatpak}")), "chrome (flatpak) · Default");
assert_eq!(label("brave+GNOMEKEYRING:Profile 1"), "brave · Profile 1");
assert_eq!(label("firefox"), "firefox");
}
#[test]
fn adds_the_keyring_to_chromium_specs_only() {
assert_eq!(with_keyring("brave:Default", "GNOMEKEYRING"), "brave+GNOMEKEYRING:Default");
assert_eq!(with_keyring("chrome", "KWALLET6"), "chrome+KWALLET6");
assert_eq!(with_keyring("brave+BASICTEXT:Default", "GNOMEKEYRING"), "brave+BASICTEXT:Default");
assert_eq!(with_keyring("firefox:ab12.default", "GNOMEKEYRING"), "firefox:ab12.default");
assert_eq!(with_keyring("brave:Default", ""), "brave:Default");
assert_eq!(resolve_keyring("KWALLET5"), "KWALLET5");
}
#[test]
fn explains_why_the_browser_could_not_be_read() {
let spec = "brave+GNOMEKEYRING:Default";
let error = |stderr: &str| cookie_error(spec, stderr, Lang::Es).unwrap_or_default();
assert!(error("WARNING: cannot decrypt v11 cookies: no key found\n").contains("brave · Default"));
let missing = "ERROR: secretstorage not available No module named 'secretstorage'\n\
WARNING: cannot decrypt v11 cookies: no key found\n";
assert!(error(missing).contains("secretstorage"), "the cause wins over its effect");
assert!(error("ERROR: kwallet-query command not found. KWallet…").contains("kwallet-query"));
assert!(error("ERROR: failed to read from keyring\n").contains("clave de brave"));
assert!(error("ERROR: Could not copy Chrome cookie database. See …").contains("cerralo"));
assert_eq!(cookie_error(spec, "WARNING: something else\n", Lang::Es), None);
let english = cookie_error(spec, "ERROR: failed to read from keyring\n", Lang::En).unwrap();
assert!(english.contains("brave's key isn't in the keyring"), "{english}");
}
#[test]
fn a_jar_without_youtube_login_says_how_to_get_one() {
assert!(has_login(".youtube.com\tTRUE\t/\tTRUE\t0\tSAPISID\tabc\n"));
assert!(!has_login(".youtube.com\tTRUE\t/\tTRUE\t0\tYSC\tabc\n"));
let message = no_login("firefox:ab12.default", Lang::Es);
assert!(message.contains("firefox · ab12.default no tiene sesión"), "{message}");
assert!(message.contains("entrá a youtube.com en firefox"), "{message}");
assert!(message.contains("Releer la sesión"), "{message}");
let english = no_login("firefox:ab12.default", Lang::En);
assert!(english.contains("has no YouTube login") && english.contains("Read the login again"), "{english}");
}
#[test]
fn cookies_path_is_per_profile_and_filename_safe() {
let p = cookies_path(Path::new("/c"), "brave+GNOMEKEYRING:Profile 1");
assert_eq!(p, Path::new("/c/cookies-brave_GNOMEKEYRING_Profile_1.txt"));
}
#[test]
fn freshness() {
let path = temp_dir("fresh").join("f");
assert!(!is_fresh(&path, Duration::from_secs(60)));
std::fs::write(&path, "").unwrap();
assert!(is_fresh(&path, Duration::from_secs(60)));
assert!(!is_fresh(&path, Duration::ZERO));
}
}