feat: add full descriptions in a scrollable popup

This commit is contained in:
2026-10-09 21:07:19 +02:00
parent 79a14dce1e
commit 3c848abeff
3 changed files with 228 additions and 8 deletions
+52 -2
View File
@@ -13,6 +13,9 @@ pub struct Video {
pub id: String,
pub title: String,
pub channel: Option<String>,
// Search descriptions are truncated; only full video metadata populates this cache.
#[serde(skip)]
pub description: Option<String>,
}
#[derive(Deserialize)]
@@ -42,6 +45,22 @@ pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
Ok(videos)
}
pub fn full_description(output: &str, expected_id: &str) -> Result<String, String> {
#[derive(Deserialize)]
struct Details {
id: String,
description: Option<String>,
}
let details: Details =
serde_json::from_str(output).map_err(|error| format!("Invalid video metadata: {error}"))?;
if details.id != expected_id {
return Err("Video metadata did not match the requested video".into());
}
let mut description = details.description.unwrap_or_default();
description.retain(|c| !c.is_control() || c == '\n' || c == '\t');
Ok(description)
}
// Own the extractor so replacing a request or quitting also cancels its process.
pub struct Job {
child: Child,
@@ -50,9 +69,25 @@ pub struct Job {
impl Job {
pub fn search(query: &str) -> io::Result<Self> {
Self::spawn(&[
"--flat-playlist",
"--dump-single-json",
&format!("ytsearch20:{query}"),
])
}
pub fn description(id: &str) -> io::Result<Self> {
Self::spawn(&[
"--no-playlist",
"--skip-download",
"--dump-single-json",
&format!("https://www.youtube.com/watch?v={id}"),
])
}
fn spawn(args: &[&str]) -> io::Result<Self> {
let mut child = extractor()
.args(["--flat-playlist", "--dump-single-json"])
.arg(format!("ytsearch20:{query}"))
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -265,4 +300,19 @@ mod tests {
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
assert!(search_results("not JSON").is_err());
}
#[test]
fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() {
let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#;
assert_eq!(
full_description(metadata, "jNQXAC9IVRw").unwrap(),
"First paragraph.\n\n日本語\tsecond paragraph.\nLast line."
);
assert!(full_description(metadata, "rQ_J9WH6CGk").is_err());
assert!(full_description("not JSON", "jNQXAC9IVRw").is_err());
assert_eq!(
full_description(r#"{"id":"jNQXAC9IVRw","description":null}"#, "jNQXAC9IVRw").unwrap(),
""
);
}
}