@@ -12,6 +12,7 @@ use crate::video_tools::{VideoError, ensure_tool_versions};
1212use crate :: video_types:: { FetchedVideo , ProbeResult , VideoInfo } ;
1313use gettextrs:: gettext;
1414use std:: path:: { Path , PathBuf } ;
15+ use std:: process:: ExitStatus ;
1516use std:: time:: Duration ;
1617use yt_dlp:: client:: deps:: Libraries ;
1718use 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).
0 commit comments