From 78b0c92e9b9fd136231a1e0d09c454f23b6e8b69 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 18 Oct 2026 12:21:17 +0200 Subject: [PATCH] feat: include duration in YouTube entry metadata --- README.md | 1 + src/main.rs | 19 +++++++++++++++++++ src/miruro_browser.rs | 1 + src/video_state.rs | 9 +++++++++ src/youtube.rs | 1 + tests/unit/app.rs | 9 ++++++++- tests/unit/video_state.rs | 2 ++ 7 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c883634..eac2914 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting. Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. Entries use two lines: the title above dimmed metadata. YouTube shows the channel and publication date beneath each video title, without separate channel/date columns; tagged channels remain cyan. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and title/episode metadata. +YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable. Selecting a YouTube video launches: diff --git a/src/main.rs b/src/main.rs index ca9ea14..82b407a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3019,6 +3019,25 @@ impl App { .as_deref() .unwrap_or("Unknown") .into(), + " · ".into(), + video + .duration + .filter(|seconds| seconds.is_finite() && *seconds >= 0.0) + .map(|seconds| { + let seconds = seconds as u64; + if seconds >= 3600 { + format!( + "{}:{:02}:{:02}", + seconds / 3600, + seconds / 60 % 60, + seconds % 60 + ) + } else { + format!("{}:{:02}", seconds / 60, seconds % 60) + } + }) + .unwrap_or_else(|| "Unknown duration".into()) + .into(), ]) .dim(), ])), diff --git a/src/miruro_browser.rs b/src/miruro_browser.rs index 02c15f5..226d906 100644 --- a/src/miruro_browser.rs +++ b/src/miruro_browser.rs @@ -199,6 +199,7 @@ fn video(id: String, title: String, date: Option) -> Video { channel_id: None, uploader_id: None, timestamp: None, + duration: None, publication_date: date, availability: None, } diff --git a/src/video_state.rs b/src/video_state.rs index cd4adff..3b058c3 100644 --- a/src/video_state.rs +++ b/src/video_state.rs @@ -716,6 +716,12 @@ fn load_videos( item.as_integer() .ok_or_else(|| invalid("Video timestamp must be an integer"))?, ), + "duration" => serde_json::Value::from( + item.as_float() + .or_else(|| item.as_integer().map(|n| n as f64)) + .filter(|n| n.is_finite() && *n >= 0.0) + .ok_or_else(|| invalid("Video duration must be a nonnegative number"))?, + ), _ => return Err(invalid(format!("Unknown saved video field {key:?}"))), }; fields.insert(key.into(), field); @@ -774,6 +780,9 @@ fn video_document<'a>(videos: impl Iterator) -> DocumentMut { if let Some(timestamp) = video.timestamp { table["timestamp"] = value(timestamp); } + if let Some(duration) = video.duration { + table["duration"] = value(duration); + } tables.push(table); } document["videos"] = if tables.is_empty() { diff --git a/src/youtube.rs b/src/youtube.rs index 99c2b30..3346c1c 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -137,6 +137,7 @@ pub struct Video { pub channel_id: Option, pub uploader_id: Option, pub timestamp: Option, + pub duration: Option, /// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date. #[serde(default, rename = "upload_date")] pub publication_date: Option, diff --git a/tests/unit/app.rs b/tests/unit/app.rs index 987755f..4ac6b84 100644 --- a/tests/unit/app.rs +++ b/tests/unit/app.rs @@ -2993,7 +2993,8 @@ fn video_channel_colors_follow_category_membership_and_preserve_selection_contra &serde_json::json!({ "entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({ "id": "jNQXAC9IVRw", "title": "Video title", "channel": channel, - "channel_id": id, "uploader_id": handle, "upload_date": "20261011" + "channel_id": id, "uploader_id": handle, "upload_date": "20261011", + "duration": 3723.5 })).collect::>() }) .to_string(), @@ -3028,6 +3029,10 @@ fn video_channel_colors_follow_category_membership_and_preserve_selection_contra assert!(title.contains("Video title")); let date_x = row.find("2026-10-11").expect("date follows channel") as u16; assert!(row.contains(&format!("{label} · 2026-10-11"))); + assert!( + row.contains(" · 1:02:03"), + "duration is visible in metadata" + ); assert_eq!(buffer[(3, y - 1)].fg, Color::Reset, "title stays uncolored"); assert_eq!(buffer[(date_x, y)].fg, Color::Reset, "date stays uncolored"); assert!( @@ -5152,6 +5157,7 @@ fn modal_keys_edit_or_navigate_without_submitting_on_escape() { channel_id: None, uploader_id: None, timestamp: None, + duration: None, publication_date: None, availability: None, }, @@ -5162,6 +5168,7 @@ fn modal_keys_edit_or_navigate_without_submitting_on_escape() { channel_id: None, uploader_id: None, timestamp: None, + duration: None, publication_date: None, availability: None, }, diff --git a/tests/unit/video_state.rs b/tests/unit/video_state.rs index f2ef533..5cf60ed 100644 --- a/tests/unit/video_state.rs +++ b/tests/unit/video_state.rs @@ -34,6 +34,7 @@ fn video() -> Video { channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()), uploader_id: Some("@jawed".into()), timestamp: Some(1_114_300_000), + duration: Some(19.0), publication_date: Some("2005-04-23".into()), availability: None, } @@ -66,6 +67,7 @@ fn miruro_video(episode: u32) -> Video { channel_id: None, uploader_id: None, timestamp: Some(1_114_300_000), + duration: None, publication_date: Some("2005-04-23".into()), availability: None, }