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
13
src/app.rs
13
src/app.rs
|
|
@ -1088,7 +1088,18 @@ impl App {
|
||||||
self.settings_cookies_input.clear();
|
self.settings_cookies_input.clear();
|
||||||
let (exists, n) = crate::web::cookies_status();
|
let (exists, n) = crate::web::cookies_status();
|
||||||
self.settings_cookies_status = if exists {
|
self.settings_cookies_status = if exists {
|
||||||
format!("{n} cookie(s) loaded")
|
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 {
|
} else {
|
||||||
"no cookies.txt".to_string()
|
"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
|
/// 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
|
/// [`cookies_path`]. Returns the number of cookie entries written, or an error
|
||||||
/// message if the content doesn't look like a cookies.txt.
|
/// 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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn srt_to_vtt_replaces_comma_in_timestamps_only() {
|
fn srt_to_vtt_replaces_comma_in_timestamps_only() {
|
||||||
let srt = "1\n00:00:01,500 --> 00:00:03,000\nHello, world\n";
|
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(())
|
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 {
|
async fn get_cookies() -> impl IntoResponse {
|
||||||
let (exists, count) = cookies_status();
|
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)]
|
#[derive(Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1386,6 +1386,16 @@ async function openSettings(){
|
||||||
const logoutBtn=cur.download_password_required?`<button onclick="logout()">Log out</button>`:'';
|
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{}
|
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';
|
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()};
|
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">
|
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>
|
<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">
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
|
||||||
<label>Cookies (cookies.txt)</label>
|
<label>Cookies (cookies.txt)</label>
|
||||||
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div>
|
<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)">
|
<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>
|
<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">
|
<div style="display:flex;gap:6px">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue