Skip to content

Commit 0caea50

Browse files
author
houssemko
committed
Retry without browser cookies after keyring extraction failure
yt-dlp walks the default keyring collection and aborts with `ERROR: Item does not exist!` when it reaches a stale item path before the valid Brave "Safe Storage" entry. The profile, cookie DB, and Brave key item are readable; the same failure reproduces outside the Flatpak. A Secret Service backend switch (for example oo7 and gnome-keyring) can leave such ghost paths in the collection. Retry the probe once without --cookies-from-browser when extraction fails and no staged handoff jar exists, and latch that state for the session so later previews/downloads do not repeat the broken keyring walk. Extension handoff jars still win; changing the browser preference or restarting clears the latch. If the retry also fails, the error says that browser cookies could not be read and were skipped.
1 parent 36cd62c commit 0caea50

5 files changed

Lines changed: 270 additions & 30 deletions

File tree

‎src/extension_server.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ fn handoff_entry(url: &str) -> Option<HandoffCookies> {
474474
/// Browser UA staged with a handoff's cookies, if any. Same lookup as the
475475
/// cookies (exact URL, then same-host fallback).
476476
pub(crate) fn handoff_user_agent(url: &str, browser: &str) -> Option<String> {
477+
let browser = crate::video_tools::effective_cookies_browser(browser);
477478
let live_browser = crate::video_tools::cookies_browser_spec(browser).is_some();
478479
let now = SystemTime::now();
479480
handoff_entry(url)
@@ -530,6 +531,7 @@ fn ytdlp_spawn_identity_at(cookies_browser: &str, page_url: &str, now: SystemTim
530531
/// `--cookies-from-browser` matters: yt-dlp aborts the whole spawn when its
531532
/// keyring extraction fails, even when the jar would have worked.
532533
pub(crate) fn ytdlp_spawn_identity(cookies_browser: &str, page_url: &str) -> Vec<String> {
534+
let cookies_browser = crate::video_tools::effective_cookies_browser(cookies_browser);
533535
ytdlp_spawn_identity_at(cookies_browser, page_url, SystemTime::now())
534536
}
535537

@@ -548,6 +550,7 @@ pub(crate) fn handoff_cookie_args(url: &str) -> Vec<String> {
548550
/// browser source. A stale snapshot would override rotated cookies exported
549551
/// from the live browser, so withhold it and let gallery-dl use the export.
550552
pub(crate) fn live_handoff_cookie_path(url: &str, browser: &str) -> Option<PathBuf> {
553+
let browser = crate::video_tools::effective_cookies_browser(browser);
551554
live_handoff_cookie_path_at(url, browser, SystemTime::now())
552555
}
553556

‎src/preferences.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,8 @@ pub fn show(
864864
let grant = cookies_override_row.downgrade();
865865
let restart = cookies_restart_row.downgrade();
866866
settings.connect_changed(Some(crate::settings::key::COOKIES_BROWSER), move |s, _| {
867+
// A new browser choice may have a working cookie source again.
868+
crate::video_tools::clear_browser_cookies_failed();
867869
if let (Some(grant), Some(restart)) = (grant.upgrade(), restart.upgrade()) {
868870
sync_cookies_override_row(&grant, &restart, s);
869871
}

‎src/video_spawn.rs‎

Lines changed: 111 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::video_tools::{VideoError, ensure_tool_versions};
1212
use crate::video_types::{FetchedVideo, ProbeResult, VideoInfo};
1313
use gettextrs::gettext;
1414
use std::path::{Path, PathBuf};
15+
use std::process::ExitStatus;
1516
use std::time::Duration;
1617
use yt_dlp::client::deps::Libraries;
1718
use yt_dlp::model::Video;
@@ -45,38 +46,28 @@ pub(crate) async fn read_bounded(
4546
))
4647
}
4748

48-
/// Fetch one page's raw `--dump-single-json`, then parse leniently. `flat_playlist` lists collection stubs; false runs full extraction.
49-
pub(crate) async fn fetch_raw_dump_json(
49+
/// One yt-dlp spawn's captured output. Callers decide whether a failed run is
50+
/// retryable.
51+
struct SpawnOutput {
52+
status: ExitStatus,
53+
stdout: Vec<u8>,
54+
stderr: Vec<u8>,
55+
}
56+
57+
/// Run one yt-dlp process with bounded concurrent drains and timeout kill.
58+
async fn run_ytdlp_once(
5059
youtube_bin: &Path,
51-
url: &str,
52-
cookies_browser: &str,
53-
timeout: Duration,
60+
args: &[String],
5461
fetch_proxy: Option<&crate::net_types::ResolvedProxy>,
55-
flat_playlist: bool,
56-
) -> Result<serde_json::Value, VideoError> {
57-
let mut args = vec!["--ignore-config".to_string(), "--no-progress".to_string()];
58-
if flat_playlist {
59-
args.push("--flat-playlist".to_string());
60-
}
61-
args.push("--dump-single-json".to_string());
62-
args.extend(proxy_cli_args(fetch_proxy));
63-
// Extension handoff (Cloudflare bypass): staged by the handoff receiver,
64-
// peeked here. The entry outlives the probe — the subtitle probe and the
65-
// media download spawn yt-dlp separately and need it too; the finished
66-
// download discards it. The tail ends with `-- <url>`, past which yt-dlp
67-
// reads positionals.
68-
args.extend(crate::extension_server::ytdlp_spawn_identity(
69-
cookies_browser,
70-
url,
71-
));
72-
// Direct spawn with the same semantics (concurrent drain, timeout kill, stderr detail on failure).
62+
timeout: Duration,
63+
) -> Result<SpawnOutput, VideoError> {
7364
let mut cmd = ytdlp_command(youtube_bin);
7465
// Belt-and-braces with the ProcessGroupGuard below (which SIGKILLs the
7566
// process group on drop): if the drain is dropped, the child dies with
7667
// it either way. Download/capture spawns keep their guard-only shutdown
7768
// behavior; never set this via the shared ytdlp_command() builder.
7869
cmd.kill_on_drop(true);
79-
cmd.args(&args);
70+
cmd.args(args);
8071
apply_proxy_env(&mut cmd, fetch_proxy);
8172
// Debug: log the full argv before spawning. as_std() captures everything
8273
// including ytdlp_command()'s --impersonate flag.
@@ -131,13 +122,104 @@ pub(crate) async fn fetch_raw_dump_json(
131122
// Debug: echo the probe's stderr (not live — the probe is short-lived,
132123
// so post-hoc is fine).
133124
crate::backend_debug::echo_stderr(&stderr);
134-
if !status.success() {
135-
let detail = last_log_line(&String::from_utf8_lossy(&stderr), "yt-dlp reported failure");
125+
Ok(SpawnOutput {
126+
status,
127+
stdout,
128+
stderr,
129+
})
130+
}
131+
132+
/// yt-dlp reports a missing keyring item as the bare secretstorage error;
133+
/// these are cookie-source failures a no-cookie retry can recover from.
134+
fn is_browser_cookie_error(stderr: &str) -> bool {
135+
let stderr = stderr.to_ascii_lowercase();
136+
[
137+
"item does not exist",
138+
"failed to extract cookies from browser",
139+
"cookies database",
140+
"secretstorage",
141+
"failed to decrypt",
142+
"could not decrypt",
143+
"keyring",
144+
]
145+
.iter()
146+
.any(|needle| stderr.contains(needle))
147+
}
148+
149+
/// True when a failed spawn has a direct browser cookie source and no staged
150+
/// jar to preserve, and its stderr points at cookie extraction.
151+
pub(crate) fn should_retry_without_cookies(args: &[String], stderr: &str) -> bool {
152+
args.iter()
153+
.any(|a| a.starts_with("--cookies-from-browser="))
154+
&& !args.iter().any(|a| a == "--cookies")
155+
&& is_browser_cookie_error(stderr)
156+
}
157+
158+
/// Remove only the direct browser cookie flag; a staged jar, identity flags,
159+
/// proxy flags and the `-- <url>` tail stay intact.
160+
pub(crate) fn without_browser_cookies(args: &[String]) -> Vec<String> {
161+
args.iter()
162+
.filter(|a| !a.starts_with("--cookies-from-browser="))
163+
.cloned()
164+
.collect()
165+
}
166+
167+
/// Fetch one page's raw `--dump-single-json`, then parse leniently. `flat_playlist` lists collection stubs; false runs full extraction.
168+
pub(crate) async fn fetch_raw_dump_json(
169+
youtube_bin: &Path,
170+
url: &str,
171+
cookies_browser: &str,
172+
timeout: Duration,
173+
fetch_proxy: Option<&crate::net_types::ResolvedProxy>,
174+
flat_playlist: bool,
175+
) -> Result<serde_json::Value, VideoError> {
176+
let mut args = vec!["--ignore-config".to_string(), "--no-progress".to_string()];
177+
if flat_playlist {
178+
args.push("--flat-playlist".to_string());
179+
}
180+
args.push("--dump-single-json".to_string());
181+
args.extend(proxy_cli_args(fetch_proxy));
182+
// Extension handoff (Cloudflare bypass): staged by the handoff receiver,
183+
// peeked here. The entry outlives the probe — the subtitle probe and the
184+
// media download spawn yt-dlp separately and need it too; the finished
185+
// download discards it. The tail ends with `-- <url>`, past which yt-dlp
186+
// reads positionals.
187+
args.extend(crate::extension_server::ytdlp_spawn_identity(
188+
cookies_browser,
189+
url,
190+
));
191+
let mut retried_without_cookies = false;
192+
loop {
193+
let output = run_ytdlp_once(youtube_bin, &args, fetch_proxy, timeout).await?;
194+
if output.status.success() {
195+
let value: serde_json::Value =
196+
serde_json::from_str(&String::from_utf8_lossy(&output.stdout))
197+
.map_err(VideoError::fetch)?;
198+
return Ok(value);
199+
}
200+
let stderr = String::from_utf8_lossy(&output.stderr);
201+
let detail = last_log_line(&stderr, "yt-dlp reported failure");
202+
if !retried_without_cookies && should_retry_without_cookies(&args, &stderr) {
203+
retried_without_cookies = true;
204+
crate::video_tools::mark_browser_cookies_failed();
205+
tracing::warn!(
206+
host = %page_host(url),
207+
error = %detail,
208+
"browser cookie extraction failed; retrying without cookies"
209+
);
210+
args = without_browser_cookies(&args);
211+
continue;
212+
}
213+
let detail = if retried_without_cookies {
214+
format!(
215+
"{detail} ({})",
216+
gettext("browser cookies couldn't be read; retried without cookies")
217+
)
218+
} else {
219+
detail
220+
};
136221
return Err(VideoError::fetch(detail));
137222
}
138-
let value: serde_json::Value =
139-
serde_json::from_str(&String::from_utf8_lossy(&stdout)).map_err(VideoError::fetch)?;
140-
Ok(value)
141223
}
142224

143225
/// Strict single-video model for the download worker (full extraction; playlist output selects the picked entry or expands worker-side).

‎src/video_tests.rs‎

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ use crate::video_runner::{
4141
};
4242
use crate::video_spawn::{
4343
ProcessGroupGuard, await_group_quiescence, fetch_raw_dump_json, fetch_video_page, reap_child,
44-
ytdlp_command,
44+
should_retry_without_cookies, without_browser_cookies, ytdlp_command,
4545
};
4646
use crate::video_staging::{
4747
ResumePlan, ResumeQuery, VideoManifest, clean_dest_parts, clean_staging, collect_sidecar,
@@ -6769,6 +6769,132 @@ fn fetch_video_page_surfaces_stderr_tail() {
67696769
let _ = std::fs::remove_dir_all(&dir);
67706770
}
67716771

6772+
#[test]
6773+
fn browser_cookie_retry_only_recovers_extraction_failures() {
6774+
let args = vec![
6775+
"--cookies-from-browser=brave:/profile".to_string(),
6776+
"--".to_string(),
6777+
"https://example.com/".to_string(),
6778+
];
6779+
for stderr in [
6780+
"ERROR: Item does not exist!",
6781+
"ERROR: could not find brave cookies database in \"/home/u/.config\"",
6782+
"ERROR: Failed to extract cookies from browser. Check '--cookies-from-browser'",
6783+
"ERROR: secretstorage is not installed",
6784+
"ERROR: Failed to decrypt with DPAPI",
6785+
"ERROR: Could not decrypt cookie file",
6786+
"ERROR: keyring is locked",
6787+
] {
6788+
assert!(
6789+
should_retry_without_cookies(&args, stderr),
6790+
"expected a retry for {stderr}"
6791+
);
6792+
}
6793+
for stderr in [
6794+
"ERROR: [youtube] Sign in to confirm you're not a bot. Use --cookies-from-browser",
6795+
"ERROR: Video unavailable",
6796+
] {
6797+
assert!(
6798+
!should_retry_without_cookies(&args, stderr),
6799+
"unexpected retry for {stderr}"
6800+
);
6801+
}
6802+
}
6803+
6804+
#[test]
6805+
fn browser_cookie_retry_preserves_a_staged_jar() {
6806+
let args = vec![
6807+
"--cookies-from-browser=brave:/profile".to_string(),
6808+
"--cookies".to_string(),
6809+
"/tmp/grab-cookies.jar".to_string(),
6810+
"--".to_string(),
6811+
"https://example.com/".to_string(),
6812+
];
6813+
assert!(!should_retry_without_cookies(
6814+
&args,
6815+
"ERROR: Item does not exist!"
6816+
));
6817+
}
6818+
6819+
#[test]
6820+
fn browser_cookie_retry_drops_only_the_browser_flag() {
6821+
let args = vec![
6822+
"--ignore-config".to_string(),
6823+
"--cookies-from-browser=brave:/profile".to_string(),
6824+
"--user-agent".to_string(),
6825+
"Grab/1".to_string(),
6826+
"--".to_string(),
6827+
"https://example.com/".to_string(),
6828+
];
6829+
assert_eq!(
6830+
without_browser_cookies(&args),
6831+
vec![
6832+
"--ignore-config".to_string(),
6833+
"--user-agent".to_string(),
6834+
"Grab/1".to_string(),
6835+
"--".to_string(),
6836+
"https://example.com/".to_string(),
6837+
]
6838+
);
6839+
}
6840+
6841+
#[test]
6842+
fn browser_cookie_keyring_failure_retries_without_cookies() {
6843+
crate::video_tools::clear_browser_cookies_failed();
6844+
let dir = std::env::temp_dir().join(format!("grab-cookie-retry-{}", std::process::id()));
6845+
let _ = std::fs::remove_dir_all(&dir);
6846+
std::fs::create_dir_all(&dir).unwrap();
6847+
let log = dir.join("args.log");
6848+
let bin = dir.join("fake-ytdlp");
6849+
std::fs::write(
6850+
&bin,
6851+
probe_guard(&format!(
6852+
"#!/bin/sh\n\
6853+
for a in \"$@\"; do printf '%s\\n' \"$a\" >> \"{log}\"; done\n\
6854+
printf '%s\\n' '---' >> \"{log}\"\n\
6855+
for a in \"$@\"; do\n\
6856+
case \"$a\" in --cookies-from-browser=*) echo 'ERROR: Item does not exist!' >&2; exit 1;; esac\n\
6857+
done\n\
6858+
echo '{{\"title\":\"ok\"}}'\n",
6859+
log = log.display()
6860+
)),
6861+
)
6862+
.unwrap();
6863+
#[cfg(unix)]
6864+
{
6865+
use std::os::unix::fs::PermissionsExt as _;
6866+
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
6867+
}
6868+
let res = crate::runtime::tokio_rt().block_on(fetch_raw_dump_json(
6869+
&bin,
6870+
"https://cookie-retry.invalid/v",
6871+
"brave",
6872+
std::time::Duration::from_secs(5),
6873+
None,
6874+
false,
6875+
));
6876+
assert!(res.is_ok(), "retry should recover: {res:?}");
6877+
let calls = std::fs::read_to_string(&log).unwrap();
6878+
let mut attempts = calls.split("---").filter(|part| !part.trim().is_empty());
6879+
let first = attempts.next().expect("first attempt");
6880+
let retry = attempts.next().expect("retry attempt");
6881+
assert!(
6882+
first.contains("--cookies-from-browser="),
6883+
"first attempt must use browser cookies: {first}"
6884+
);
6885+
assert!(
6886+
!retry.contains("--cookies-from-browser="),
6887+
"retry must drop browser cookies: {retry}"
6888+
);
6889+
assert_eq!(
6890+
crate::video_tools::effective_cookies_browser("brave"),
6891+
"none",
6892+
"the session latch must keep later spawns cookie-free"
6893+
);
6894+
crate::video_tools::clear_browser_cookies_failed();
6895+
let _ = std::fs::remove_dir_all(&dir);
6896+
}
6897+
67726898
#[test]
67736899
fn fetch_video_page_rejects_garbage_stdout() {
67746900
// Zero exit with non-JSON stdout is a parse error, not a phantom video.

‎src/video_tools.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::download_net::tool_client_builder_proxied;
66
use gettextrs::gettext;
77
use std::collections::HashMap;
88
use std::path::{Path, PathBuf};
9+
use std::sync::atomic::{AtomicBool, Ordering};
910
use std::sync::{Mutex, OnceLock};
1011
use thiserror::Error;
1112
use yt_dlp::client::deps::Libraries;
@@ -1611,6 +1612,32 @@ pub(crate) fn cookies_browser_spec(value: &str) -> Option<String> {
16111612
Some(ytdlp_browser.to_string())
16121613
}
16131614

1615+
/// One-way per-session latch: once direct browser-cookie extraction fails
1616+
/// (e.g. a dangling keyring item), later spawns skip browser cookies so a
1617+
/// preview or download can still run. A staged handoff jar still wins.
1618+
/// Cleared when the browser preference changes or the app restarts.
1619+
static BROWSER_COOKIES_FAILED: AtomicBool = AtomicBool::new(false);
1620+
1621+
/// Mark direct browser-cookie extraction as failed for the rest of the session.
1622+
pub(crate) fn mark_browser_cookies_failed() {
1623+
BROWSER_COOKIES_FAILED.store(true, Ordering::Relaxed);
1624+
}
1625+
1626+
/// Allow direct browser cookies again after the user picks a browser.
1627+
pub(crate) fn clear_browser_cookies_failed() {
1628+
BROWSER_COOKIES_FAILED.store(false, Ordering::Relaxed);
1629+
}
1630+
1631+
/// Browser value for spawns: `none` once this session has seen a direct
1632+
/// cookie-extraction failure, so the broken source is not retried per spawn.
1633+
pub(crate) fn effective_cookies_browser(browser: &str) -> &str {
1634+
if BROWSER_COOKIES_FAILED.load(Ordering::Relaxed) && browser != "none" {
1635+
"none"
1636+
} else {
1637+
browser
1638+
}
1639+
}
1640+
16141641
/// Shared trailing argv for every yt-dlp spawn: player-client workaround, JS
16151642
/// runtime pin for YouTube, cookies, user agent, then the page URL
16161643
/// behind `--`. One helper so these flags cannot drift between spawns (or let

0 commit comments

Comments
 (0)