@@ -283,7 +283,7 @@ fn enumerate_base_argv(jsonl: bool) -> Vec<String> {
283283 "extractor.cookies-update=false" . to_string( ) ,
284284 ] ;
285285 if jsonl {
286- // One JSON message per line, flushed as it arrives (see parse_jsonl ).
286+ // One JSON message per line, flushed as it arrives (see parse_jsonl_line ).
287287 argv. push ( "-o" . to_string ( ) ) ;
288288 argv. push ( "output.jsonl=true" . to_string ( ) ) ;
289289 }
@@ -384,10 +384,10 @@ pub(crate) async fn enumerate_gallery_files_streamed(
384384 use tokio:: io:: AsyncBufReadExt as _;
385385
386386 let mut cmd = enumerate_command ( tool, url, cookies_file, user_agent, filter, true ) ;
387- // stdout is the line stream; stderr is discarded — failures reach us
388- // through the exit status and the document-mode fallback below .
387+ // stdout is the line stream; stderr is kept as a short tail for the
388+ // error text, drained concurrently so gallery-dl never blocks on a full pipe .
389389 cmd. stdout ( std:: process:: Stdio :: piped ( ) )
390- . stderr ( std:: process:: Stdio :: null ( ) ) ;
390+ . stderr ( std:: process:: Stdio :: piped ( ) ) ;
391391 // Debug: log the full argv before spawning.
392392 crate :: backend_debug:: log_command ( & cmd) ;
393393 let mut child = cmd
@@ -397,13 +397,20 @@ pub(crate) async fn enumerate_gallery_files_streamed(
397397 . stdout
398398 . take ( )
399399 . ok_or_else ( || GalleryError :: install ( "gallery-dl enumerate: no stdout" . to_string ( ) ) ) ?;
400+ let stderr_tail = spawn_stderr_tail ( child. stderr . take ( ) ) ;
400401 let mut lines = tokio:: io:: BufReader :: new ( stdout) . lines ( ) ;
401402 let mut reader = ProbeReader :: default ( ) ;
402403 let mut count = 0usize ;
403404 let deadline = tokio:: time:: Instant :: now ( ) + STREAM_TOTAL_TIMEOUT ;
404405 loop {
405- let next = tokio:: time:: timeout ( STREAM_IDLE_TIMEOUT , lines. next_line ( ) ) ;
406- let line = match next. await {
406+ // Wake at the idle bound or the total deadline, whichever is first.
407+ let wake = ( tokio:: time:: Instant :: now ( ) + STREAM_IDLE_TIMEOUT ) . min ( deadline) ;
408+ let line = match tokio:: time:: timeout_at ( wake, lines. next_line ( ) ) . await {
409+ Err ( _) if tokio:: time:: Instant :: now ( ) >= deadline => {
410+ return Err ( GalleryError :: probe (
411+ "gallery-dl enumerate timed out" . to_string ( ) ,
412+ ) ) ;
413+ }
407414 Err ( _) => {
408415 return Err ( GalleryError :: probe (
409416 "gallery-dl enumerate stalled (no output)" . to_string ( ) ,
@@ -417,14 +424,8 @@ pub(crate) async fn enumerate_gallery_files_streamed(
417424 Ok ( Ok ( None ) ) => break ,
418425 Ok ( Ok ( Some ( line) ) ) => line,
419426 } ;
420- if tokio:: time:: Instant :: now ( ) > deadline {
421- return Err ( GalleryError :: probe (
422- "gallery-dl enumerate timed out" . to_string ( ) ,
423- ) ) ;
424- }
425427 // Blank lines carry no message.
426- let mut messages = parse_jsonl ( line. as_bytes ( ) ) ?;
427- let Some ( m) = messages. pop ( ) else {
428+ let Some ( m) = parse_jsonl_line ( & line) ? else {
428429 continue ;
429430 } ;
430431 if let Some ( item) = reader. feed ( & m) {
@@ -442,8 +443,9 @@ pub(crate) async fn enumerate_gallery_files_streamed(
442443 . map_err ( |e| GalleryError :: install ( format ! ( "gallery-dl enumerate failed: {e}" ) ) ) ?;
443444 if !status. success ( ) {
444445 return Err ( GalleryError :: install ( format ! (
445- "gallery-dl enumerate failed (exit {})" ,
446- status. code( ) . unwrap_or( -1 )
446+ "gallery-dl enumerate failed (exit {}){}" ,
447+ status. code( ) . unwrap_or( -1 ) ,
448+ stderr_summary( & stderr_tail)
447449 ) ) ) ;
448450 }
449451 reader. finish ( ) ?;
@@ -465,30 +467,60 @@ pub(crate) async fn enumerate_gallery_files_streamed(
465467 Ok ( count)
466468}
467469
470+ /// Last few stderr lines of a streamed run, drained in the background.
471+ type StderrTail = std:: sync:: Arc < std:: sync:: Mutex < std:: collections:: VecDeque < String > > > ;
472+
473+ const STDERR_TAIL_LINES : usize = 8 ;
474+
475+ fn spawn_stderr_tail ( stderr : Option < tokio:: process:: ChildStderr > ) -> StderrTail {
476+ use tokio:: io:: AsyncBufReadExt as _;
477+ let tail: StderrTail = Default :: default ( ) ;
478+ if let Some ( stderr) = stderr {
479+ let sink = tail. clone ( ) ;
480+ tokio:: spawn ( async move {
481+ let mut lines = tokio:: io:: BufReader :: new ( stderr) . lines ( ) ;
482+ while let Ok ( Some ( line) ) = lines. next_line ( ) . await {
483+ let mut t = sink. lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
484+ if t. len ( ) == STDERR_TAIL_LINES {
485+ t. pop_front ( ) ;
486+ }
487+ t. push_back ( line) ;
488+ }
489+ } ) ;
490+ }
491+ tail
492+ }
493+
494+ /// `": line | line"` for the error text, or empty when stderr said nothing.
495+ fn stderr_summary ( tail : & StderrTail ) -> String {
496+ let t = tail. lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
497+ if t. is_empty ( ) {
498+ return String :: new ( ) ;
499+ }
500+ format ! ( ": {}" , t. iter( ) . cloned( ) . collect:: <Vec <_>>( ) . join( " | " ) )
501+ }
502+
468503/// Parse `gallery-dl -o output.jsonl=true` output: one JSON message array
469504/// per line, blank lines ignored. A malformed line is an error — silently
470505/// skipping one would shift every later index away from what `--range`
471506/// expects on the download side.
472- fn parse_jsonl ( bytes : & [ u8 ] ) -> Result < Vec < serde_json:: Value > , GalleryError > {
473- let mut messages = Vec :: new ( ) ;
474- for line in bytes. split ( |b| * b == b'\n' ) {
475- if line. iter ( ) . all ( u8:: is_ascii_whitespace) {
476- continue ;
477- }
478- messages. push (
479- serde_json:: from_slice ( line) . map_err ( |e| {
480- GalleryError :: probe ( format ! ( "couldn't parse gallery-dl output: {e}" ) )
481- } ) ?,
482- ) ;
507+ fn parse_jsonl_line ( line : & str ) -> Result < Option < serde_json:: Value > , GalleryError > {
508+ if line. trim ( ) . is_empty ( ) {
509+ return Ok ( None ) ;
483510 }
484- Ok ( messages)
511+ serde_json:: from_str ( line)
512+ . map ( Some )
513+ . map_err ( |e| GalleryError :: probe ( format ! ( "couldn't parse gallery-dl output: {e}" ) ) )
485514}
486515
487516/// Streaming twin of `parse_probe_messages`: feed messages one at a time
488517/// and items come out as they arrive. Directory context and the pending
489518/// error carry across feeds exactly like the whole-vector parse.
490519#[ derive( Default ) ]
491520struct ProbeReader {
521+ /// Count of file messages seen, including unreadable ones: gallery-dl
522+ /// counts every file in `--range`, so placeholders must hold a slot.
523+ file_index : usize ,
492524 /// Directory context from the most recent type-2 message; file items
493525 /// inherit its title/user when they don't carry their own.
494526 dir_ctx : Option < serde_json:: Map < String , serde_json:: Value > > ,
@@ -498,6 +530,28 @@ struct ProbeReader {
498530 got_items : bool ,
499531}
500532
533+ /// Stand-in for a file message whose metadata is missing or unreadable.
534+ /// Keeps its position in the picker so `--range` indices stay aligned.
535+ fn placeholder_item ( url : Option < & str > , n : usize ) -> GalleryItem {
536+ let tail = url
537+ . and_then ( |u| u. split ( [ '?' , '#' ] ) . next ( ) )
538+ . and_then ( |u| u. rsplit ( '/' ) . next ( ) )
539+ . filter ( |s| !s. is_empty ( ) ) ;
540+ let filename = tail
541+ . map ( str:: to_string)
542+ . unwrap_or_else ( || format ! ( "file {n}" ) ) ;
543+ let extension = filename
544+ . rsplit_once ( '.' )
545+ . map ( |( _, e) | e. to_string ( ) )
546+ . unwrap_or_default ( ) ;
547+ GalleryItem {
548+ filename,
549+ extension,
550+ category : String :: new ( ) ,
551+ extra : serde_json:: Map :: new ( ) ,
552+ }
553+ }
554+
501555impl ProbeReader {
502556 /// Feed one message; returns the file item it carried, if any.
503557 ///
@@ -506,18 +560,16 @@ impl ProbeReader {
506560 fn feed ( & mut self , m : & serde_json:: Value ) -> Option < GalleryItem > {
507561 match m. get ( 0 ) . and_then ( |v| v. as_i64 ( ) ) {
508562 Some ( msg_type:: FILE ) => {
509- let meta = match m. get ( 2 ) {
510- Some ( v) => v. clone ( ) ,
511- None => {
512- tracing:: trace!( message = ?m, "gallery-dl probe file message without metadata" ) ;
513- return None ;
514- }
515- } ;
516- let mut item: GalleryItem = match serde_json:: from_value ( meta) {
517- Ok ( item) => item,
518- Err ( e) => {
519- tracing:: trace!( error = %e, "gallery-dl probe couldn't parse file metadata" ) ;
520- return None ;
563+ self . file_index += 1 ;
564+ let url = m. get ( 1 ) . and_then ( |v| v. as_str ( ) ) ;
565+ let mut item: GalleryItem = match m. get ( 2 ) . cloned ( ) . map ( serde_json:: from_value) {
566+ Some ( Ok ( item) ) => item,
567+ _ => {
568+ tracing:: warn!(
569+ url = ?url,
570+ "gallery-dl probe file message unreadable; keeping a placeholder"
571+ ) ;
572+ placeholder_item ( url, self . file_index )
521573 }
522574 } ;
523575 // Inherit directory title/user when the file lacks them.
@@ -677,7 +729,10 @@ mod tests {
677729 "[3, \" https://example.com/2.jpg\" , {\" filename\" : \" 2.jpg\" }]\n " ,
678730 "[-1, {\" error\" : \" HttpError\" , \" message\" : \" 404\" }]\n "
679731 ) ;
680- let messages = parse_jsonl ( jsonl. as_bytes ( ) ) . unwrap ( ) ;
732+ let messages: Vec < serde_json:: Value > = jsonl
733+ . lines ( )
734+ . filter_map ( |l| parse_jsonl_line ( l) . unwrap ( ) )
735+ . collect ( ) ;
681736 // Blank/whitespace-only lines are skipped; message order is kept.
682737 assert_eq ! ( messages. len( ) , 4 ) ;
683738 let items = parse_probe_messages ( & messages) . unwrap ( ) ;
@@ -689,8 +744,28 @@ mod tests {
689744 fn jsonl_garbage_line_is_an_error ( ) {
690745 // A skipped line would shift every later index away from the
691746 // --range the download side applies, so this must fail loudly.
692- let jsonl = b"[3, \" https://example.com/1.jpg\" , {\" filename\" : \" 1.jpg\" }]\n not json\n " ;
693- assert ! ( parse_jsonl( jsonl) . is_err( ) ) ;
747+ let jsonl = "[3, \" https://example.com/1.jpg\" , {\" filename\" : \" 1.jpg\" }]\n not json\n " ;
748+ assert ! ( jsonl. lines( ) . any( |l| parse_jsonl_line( l) . is_err( ) ) ) ;
749+ assert ! ( parse_jsonl_line( " " ) . unwrap( ) . is_none( ) ) ;
750+ }
751+
752+ #[ test]
753+ fn unreadable_file_message_keeps_its_range_slot ( ) {
754+ // gallery-dl counts this message in --range. Dropping it would shift
755+ // every later pick onto the wrong file, so it must become a placeholder.
756+ let messages: Vec < serde_json:: Value > = vec ! [
757+ serde_json:: json!( [ 3 , "https://example.com/a" , { "filename" : "a" } ] ) ,
758+ serde_json:: json!( [ 3 , "https://example.com/dir/b.jpg" ] ) ,
759+ serde_json:: json!( [ 3 , "https://example.com/c" , { "filename" : 5 } ] ) ,
760+ serde_json:: json!( [ 3 , "https://example.com/d" , { "filename" : "d" } ] ) ,
761+ ] ;
762+ let items = parse_probe_messages ( & messages) . unwrap ( ) ;
763+ assert_eq ! ( items. len( ) , 4 ) ;
764+ assert_eq ! ( items[ 0 ] . filename, "a" ) ;
765+ assert_eq ! ( items[ 1 ] . filename, "b.jpg" ) ;
766+ assert_eq ! ( items[ 1 ] . extension, "jpg" ) ;
767+ assert_eq ! ( items[ 2 ] . filename, "c" ) ;
768+ assert_eq ! ( items[ 3 ] . filename, "d" ) ;
694769 }
695770
696771 #[ test]
0 commit comments