Anti-bot: cookie freshness + anonymous-jar warning

Stale or anonymous cookies make YouTube's bot-detection WORSE than no
cookies — so warn the user before downloads start captcha-failing.

cookies_freshness() parses the Netscape jar and reports:
- expires_at / expired / days_left: earliest expiry among the auth
  cookies that actually gate login (SID, SAPISID, __Secure-*PSID,
  LOGIN_INFO, …). Non-auth cookies (PREF, consent) are ignored so their
  expiry doesn't trigger false warnings.
- no_auth_cookies: the jar has cookies but NONE of the login ones — i.e.
  it's an anonymous/visitor session, not signed in. This is the worst
  case for bot-detection and a common foot-gun (exporting cookies from a
  browser that wasn't logged in to YouTube).

Surfaced in GET /api/cookies and both Settings UIs (web + desktop) with
graded severity: anonymous (red) > expired (red) > expiring-soon
(yellow) > fine.

5 tests cover expired, fresh, session/non-auth ignored, and the
anonymous-jar case. 91 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-01 15:15:51 -07:00
parent 2f711604f0
commit d098e5f6c5
3 changed files with 168 additions and 3 deletions

View file

@ -1088,7 +1088,18 @@ impl App {
self.settings_cookies_input.clear();
let (exists, n) = crate::web::cookies_status();
self.settings_cookies_status = if exists {
let fresh = crate::web::cookies_freshness();
if fresh.no_auth_cookies {
format!("{n} cookie(s) — ⚠ ANONYMOUS (no YouTube login session); export a fresh cookies.txt while signed in — anonymous requests get captcha'd most")
} else if fresh.expired {
let ago = fresh.days_left.map(|d| d.abs()).unwrap_or(0);
format!("{n} cookie(s) — ⚠ login cookies EXPIRED ({ago}d ago); refresh them (stale cookies worsen bot-detection)")
} else if fresh.days_left.is_some_and(|d| d <= 3) {
let d = fresh.days_left.unwrap_or(0);
format!("{n} cookie(s) — ⚠ login cookies expire in {d}d; refresh soon")
} else {
format!("{n} cookie(s) loaded")
}
} else {
"no cookies.txt".to_string()
};

View file

@ -577,6 +577,79 @@ pub fn cookies_status() -> (bool, usize) {
}
}
/// Cookie-freshness assessment for the UI.
///
/// `expires_at` is the **earliest** non-session expiry among the
/// auth-relevant YouTube/Google cookies (the ones that actually gate
/// logged-in access — `SID`, `SAPISID`, `__Secure-*`, `LOGIN_INFO`).
/// `None` means none had a parseable future expiry (all session cookies,
/// or already expired). `expired` is true when that earliest expiry is in
/// the past — a strong "refresh your cookies" signal, since stale auth
/// cookies make YouTube's bot-detection *worse* than none at all.
#[derive(serde::Serialize, Default)]
pub struct CookieFreshness {
/// Earliest auth-cookie expiry as a UNIX timestamp, if any.
pub expires_at: Option<i64>,
/// True when the earliest auth-cookie expiry is already past.
pub expired: bool,
/// Days until `expires_at` (negative if expired). `None` if unknown.
pub days_left: Option<i64>,
/// True when the jar has cookies but NONE of the login/auth cookies —
/// i.e. it's an anonymous/visitor session, not a signed-in one. This
/// is the worst case for YouTube bot-detection (anonymous requests get
/// captcha'd most aggressively), so we flag it distinctly.
pub no_auth_cookies: bool,
}
/// Names whose expiry actually matters for staying logged in. Other
/// cookies (prefs, consent) expiring doesn't break auth, so we ignore
/// them to avoid false "stale" warnings.
const AUTH_COOKIE_NAMES: &[&str] = &[
"SID", "HSID", "SSID", "APISID", "SAPISID",
"__Secure-1PSID", "__Secure-3PSID", "__Secure-1PAPISID", "__Secure-3PAPISID",
"LOGIN_INFO",
];
/// Parse the cookies file and assess freshness. See [`CookieFreshness`].
pub fn cookies_freshness() -> CookieFreshness {
let text = std::fs::read_to_string(cookies_path()).unwrap_or_default();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
cookies_freshness_from(&text, now)
}
/// Pure freshness assessment from cookie-jar text + "now". Split out for
/// testability (the public fn reads the file + clock).
fn cookies_freshness_from(text: &str, now: i64) -> CookieFreshness {
// Netscape format: domain \t flag \t path \t secure \t expiry \t name \t value
let mut earliest: Option<i64> = None;
let mut saw_auth_cookie = false;
let mut saw_any_cookie = false;
for line in text.lines() {
if line.starts_with('#') || line.trim().is_empty() { continue; }
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 7 { continue; }
saw_any_cookie = true;
let name = f[5];
if !AUTH_COOKIE_NAMES.contains(&name) { continue; }
saw_auth_cookie = true;
// Expiry 0 = session cookie (no fixed expiry); skip — it's not
// a staleness signal on its own.
let Ok(exp) = f[4].parse::<i64>() else { continue };
if exp == 0 { continue; }
earliest = Some(earliest.map_or(exp, |e| e.min(exp)));
}
CookieFreshness {
expires_at: earliest,
expired: earliest.is_some_and(|exp| exp < now),
days_left: earliest.map(|exp| (exp - now) / 86_400),
// Has cookies but no login cookies = anonymous jar.
no_auth_cookies: saw_any_cookie && !saw_auth_cookie,
}
}
/// Validate that `text` looks like a Netscape cookie jar and write it to
/// [`cookies_path`]. Returns the number of cookie entries written, or an error
/// message if the content doesn't look like a cookies.txt.
@ -617,6 +690,66 @@ pub fn write_cookies(text: &str) -> Result<usize, String> {
mod tests {
use super::*;
#[test]
fn cookie_freshness_flags_expired_auth_cookie() {
let now = 1_700_000_000;
// SID expired (past), LOGIN_INFO valid (future). Earliest = SID → expired.
let jar = format!(
"# Netscape HTTP Cookie File\n\
.youtube.com\tTRUE\t/\tTRUE\t{past}\tSID\tabc\n\
.youtube.com\tTRUE\t/\tTRUE\t{future}\tLOGIN_INFO\txyz\n",
past = now - 86_400, future = now + 30 * 86_400,
);
let f = cookies_freshness_from(&jar, now);
assert!(f.expired, "earliest auth cookie is in the past → expired");
assert_eq!(f.expires_at, Some(now - 86_400));
assert_eq!(f.days_left, Some(-1));
}
#[test]
fn cookie_freshness_fresh_when_all_future() {
let now = 1_700_000_000;
let jar = format!(
".youtube.com\tTRUE\t/\tTRUE\t{f}\tSID\ta\n\
.youtube.com\tTRUE\t/\tTRUE\t{f}\tSAPISID\tb\n",
f = now + 10 * 86_400,
);
let r = cookies_freshness_from(&jar, now);
assert!(!r.expired);
assert_eq!(r.days_left, Some(10));
}
#[test]
fn cookie_freshness_ignores_non_auth_and_session_cookies() {
let now = 1_700_000_000;
// A non-auth cookie expired + a session (0) auth cookie → no signal.
let jar = format!(
".youtube.com\tTRUE\t/\tFALSE\t{past}\tPREF\tx\n\
.youtube.com\tTRUE\t/\tTRUE\t0\tSID\ta\n",
past = now - 86_400,
);
let r = cookies_freshness_from(&jar, now);
assert_eq!(r.expires_at, None);
assert!(!r.expired);
// SID present (even as session) → not an anonymous jar.
assert!(!r.no_auth_cookies);
}
#[test]
fn cookie_freshness_detects_anonymous_jar() {
let now = 1_700_000_000;
// Only visitor/pref cookies, no login cookies → anonymous.
let jar = format!(
".youtube.com\tTRUE\t/\tFALSE\t{f}\tPREF\tx\n\
.youtube.com\tTRUE\t/\tTRUE\t{f}\tVISITOR_INFO1_LIVE\ty\n\
.youtube.com\tTRUE\t/\tTRUE\t{f}\tYSC\tz\n",
f = now + 100 * 86_400,
);
let r = cookies_freshness_from(&jar, now);
assert!(r.no_auth_cookies, "no login cookies → anonymous jar flagged");
assert!(!r.expired);
}
#[test]
fn srt_to_vtt_replaces_comma_in_timestamps_only() {
let srt = "1\n00:00:01,500 --> 00:00:03,000\nHello, world\n";
@ -2255,10 +2388,20 @@ pub fn clear_cookies() -> Result<(), String> {
Ok(())
}
/// `GET /api/cookies` — report whether a cookies file exists and its entry count.
/// `GET /api/cookies` — report whether a cookies file exists, its entry
/// count, and a freshness assessment (so the UI can nudge the user to
/// refresh stale/expired auth cookies before downloads start failing).
async fn get_cookies() -> impl IntoResponse {
let (exists, count) = cookies_status();
Json(serde_json::json!({ "exists": exists, "cookies": count }))
let freshness = cookies_freshness();
Json(serde_json::json!({
"exists": exists,
"cookies": count,
"expires_at": freshness.expires_at,
"expired": freshness.expired,
"days_left": freshness.days_left,
"no_auth_cookies": freshness.no_auth_cookies,
}))
}
#[derive(Deserialize)]

View file

@ -1386,6 +1386,16 @@ async function openSettings(){
const logoutBtn=cur.download_password_required?`<button onclick="logout()">Log out</button>`:'';
let ck={exists:false,cookies:0};try{ck=await(await api('/api/cookies')).json()}catch{}
const cookiesStatus=ck.exists?`${ck.cookies} cookie(s) loaded`:'no cookies.txt';
// Freshness nudge: expired/soon-to-expire auth cookies make YouTube's
// bot-detection worse, so flag them prominently.
let cookieWarn='';
if(ck.exists&&ck.no_auth_cookies){
cookieWarn=`<div class="settings-hint" style="color:#f87171;font-weight:600">⚠ These cookies are anonymous — they have no YouTube login session (no SID/SAPISID/LOGIN_INFO). YouTube captchas anonymous requests most aggressively. Export a fresh cookies.txt while signed in to YouTube.</div>`;
}else if(ck.exists&&ck.expired){
cookieWarn=`<div class="settings-hint" style="color:#f87171;font-weight:600">⚠ Your login cookies have expired (${Math.abs(ck.days_left||0)}d ago). Refresh them below — stale cookies make YouTube's bot-detection worse.</div>`;
}else if(ck.exists&&ck.days_left!=null&&ck.days_left<=3){
cookieWarn=`<div class="settings-hint" style="color:#facc15">⚠ Login cookies expire in ${ck.days_left}d. Refresh soon to avoid captcha errors.</div>`;
}
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:420px;overflow-y:auto">
<div class="modal-hdr" style="position:sticky;top:0;background:var(--panel);z-index:1;padding-bottom:6px"><h2>Settings</h2></div>
@ -1412,6 +1422,7 @@ async function openSettings(){
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Cookies (cookies.txt)</label>
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div>
${cookieWarn}
<input type="file" id="cf-cookies-file" accept=".txt,text/plain" onchange="loadCookieFile(this)" style="font-size:11px;color:var(--muted)">
<textarea id="cf-cookies" placeholder="…or paste Netscape-format cookies.txt here" style="width:100%;height:70px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:11px;resize:vertical"></textarea>
<div style="display:flex;gap:6px">