Skip to content

Commit 95438ab

Browse files
authored
Merge pull request #442 from Linuxuser67/review-fixes-grab
Gallery picker: review fixes (R1, S1, S2, S5, S6, S7, S8)
2 parents 7a449b0 + 1c732d6 commit 95438ab

3 files changed

Lines changed: 140 additions & 52 deletions

File tree

‎src/gallery_probe.rs‎

Lines changed: 117 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
491520
struct 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+
501555
impl 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\"}]\nnot json\n";
693-
assert!(parse_jsonl(jsonl).is_err());
747+
let jsonl = "[3, \"https://example.com/1.jpg\", {\"filename\": \"1.jpg\"}]\nnot 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]

‎src/inline_add.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3061,6 +3061,11 @@ fn make_gallery_picker_opener(
30613061
}
30623062
};
30633063
}
3064+
// Items buffered before a supersede must not land on the page.
3065+
if step3.enumerate_gen.get() != my_gen {
3066+
handle.abort();
3067+
break;
3068+
}
30643069
if let Some(page) = page.as_ref() {
30653070
page.append(&batch);
30663071
}

‎src/picker_grid.rs‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ fn append_titles(store: &gio::ListStore, selection: &gtk4::MultiSelection, title
3737
return;
3838
}
3939
let start = store.n_items();
40-
for title in titles {
41-
store.append(&gtk4::StringObject::new(&title));
42-
}
40+
let objs: Vec<gtk4::StringObject> = titles.iter().map(|t| gtk4::StringObject::new(t)).collect();
41+
// One splice = one items-changed signal for the whole batch.
42+
store.splice(start, 0, &objs[..]);
4343
selection.select_range(start, n, false);
4444
}
4545

@@ -146,7 +146,12 @@ fn build_grid(selection: &gtk4::MultiSelection) -> gtk4::GridView {
146146
// MultiSelection's replace-the-whole-selection semantics to every
147147
// click that lands on the row instead of a pill.
148148
list_item.set_selectable(false);
149+
// The pill's label is ours: ellipsize once here, not on every bind.
150+
let label = gtk4::Label::new(None);
151+
label.set_ellipsize(pango::EllipsizeMode::End);
152+
label.set_max_width_chars(24);
149153
let btn = gtk4::ToggleButton::new();
154+
btn.set_child(Some(&label));
150155
// The view mirrors `ListItem:selected` from the selection model on
151156
// every bind, so this one-time binding covers recycling, Select
152157
// All/None and streamed appends. Both objects share the cell's
@@ -194,12 +199,8 @@ fn build_grid(selection: &gtk4::MultiSelection) -> gtk4::GridView {
194199
.unwrap();
195200
// Titles are untrusted (they carry `&`, `<`, …): Button label is
196201
// plain text, never markup.
197-
btn.set_label(&title);
198-
// Overlong titles ellipsize instead of forcing degenerate width
199-
// measurements: the pill keeps a sane minimum width.
200202
if let Some(label) = btn.child().and_downcast::<gtk4::Label>() {
201-
label.set_ellipsize(pango::EllipsizeMode::End);
202-
label.set_max_width_chars(24);
203+
label.set_label(&title);
203204
}
204205
});
205206
// Pills are the only selection UI: rubberband selection writes straight
@@ -236,6 +237,13 @@ mod tests {
236237
use super::*;
237238
use gtk4::glib;
238239

240+
/// Pill text now lives on the child label (see `build_grid`).
241+
fn pill_title(b: &gtk4::ToggleButton) -> Option<String> {
242+
b.child()
243+
.and_downcast::<gtk4::Label>()
244+
.map(|l| l.label().to_string())
245+
}
246+
239247
// `GtkMultiSelection::new` asserts that GTK is initialized, so the test
240248
// here needs a display and is `#[ignore]`d: CI runs it under Xvfb (the
241249
// "UI tests (Xvfb)" step in ci.yml), the plain run skips it.
@@ -337,7 +345,7 @@ mod tests {
337345
// Click path: pill -> selection model -> count sink.
338346
let first = buttons
339347
.iter()
340-
.find(|b| b.label().as_deref() == Some("item-0"))
348+
.find(|b| pill_title(b).as_deref() == Some("item-0"))
341349
.expect("item-0 pill realized");
342350
first.set_active(false);
343351
settle();
@@ -384,7 +392,7 @@ mod tests {
384392
settle();
385393
let last = collect_toggle_buttons(grid.widget().upcast_ref::<gtk4::Widget>())
386394
.into_iter()
387-
.find(|b| b.label().as_deref() == Some("item-299"))
395+
.find(|b| pill_title(b).as_deref() == Some("item-299"))
388396
.expect("bottom row must realize after scrolling");
389397
assert!(last.is_active(), "rebound cell must show item-299 selected");
390398
last.set_active(false);

0 commit comments

Comments
 (0)