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:
parent
2f711604f0
commit
d098e5f6c5
3 changed files with 168 additions and 3 deletions
11
src/app.rs
11
src/app.rs
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
147
src/web.rs
147
src/web.rs
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue