Compare commits

...

2 Commits

7 changed files with 88 additions and 31 deletions
+2 -1
View File
@@ -43,7 +43,8 @@ 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. 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. 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 two-line title/episode rows rather than YouTube's video table. 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: Selecting a YouTube video launches:
+52 -25
View File
@@ -101,7 +101,7 @@
//! Switching views cancels in-flight extraction; returning retains the unaccepted request and per-channel offsets. //! Switching views cancels in-flight extraction; returning retains the unaccepted request and per-channel offsets.
//! Errors keep loaded rows and stop automatic retries; r reloads config and restarts the current view. //! Errors keep loaded rows and stop automatic retries; r reloads config and restarts the current view.
//! Deep pages may slow down because yt-dlp offsets re-traverse earlier continuation pages. //! Deep pages may slow down because yt-dlp offsets re-traverse earlier continuation pages.
//! Video tables show Published as YYYY-MM-DD; ~ marks estimated UTC dates, Unknown missing metadata. //! Video entries show the title above channel · YYYY-MM-DD; ~ marks estimated UTC dates, Unknown missing metadata.
//! Prefer exact upload dates and cache date labels during extraction, not on every redraw. //! Prefer exact upload dates and cache date labels during extraction, not on every redraw.
//! Terebi receives `--new-instance --url` and a private `--progress-file`; each selection gets its own player. //! Terebi receives `--new-instance --url` and a private `--progress-file`; each selection gets its own player.
//! `--player` overrides its CLI path; otherwise `terebi` is resolved through PATH. //! `--player` overrides its CLI path; otherwise `terebi` is resolved through PATH.
@@ -2986,39 +2986,66 @@ impl App {
.categories .categories
.values() .values()
.any(|channels| video_in_channels(video, channels, &self.config)); .any(|channels| video_in_channels(video, channels, &self.config));
let channel = Cell::from(video.channel.as_deref().unwrap_or("Unknown")); let title = Span::raw(video.title.as_str());
let channel = Span::raw(video.channel.as_deref().unwrap_or("Unknown"));
Row::new([ Row::new([
Cell::from(if self.video_state.is_watched(&video.id) { Cell::from(if self.video_state.is_watched(&video.id) {
"✓" "✓"
} else { } else {
"" ""
}), }),
if self.video_state.contains(CollectionKind::Saved, &video.id) { Cell::from(Text::from(vec![
Cell::from(video.title.as_str()).fg(Color::Green) Line::from(
} else if self if self.video_state.contains(CollectionKind::Saved, &video.id) {
.video_state title.fg(Color::Green)
.contains(CollectionKind::WatchLater, &video.id) } else if self
{ .video_state
Cell::from(video.title.as_str()).fg(Color::Magenta) .contains(CollectionKind::WatchLater, &video.id)
} else { {
Cell::from(video.title.as_str()) title.fg(Color::Magenta)
}, } else {
if tagged { title
channel.fg(Color::Cyan) },
} else { ),
channel Line::from(vec![
}, if tagged {
Cell::from(video.publication_date.as_deref().unwrap_or("Unknown")), channel.fg(Color::Cyan)
} else {
channel
},
" · ".into(),
video
.publication_date
.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(),
])),
]) ])
.height(2)
}), }),
[ [Constraint::Length(1), Constraint::Fill(1)],
Constraint::Length(1),
Constraint::Fill(3),
Constraint::Fill(1),
Constraint::Length(11),
],
) )
.header(Row::new(["", "Video", "Channel", "Published"]).bold())
.block(Block::bordered().title(loading_title( .block(Block::bordered().title(loading_title(
if view.source == ViewSource::Search { if view.source == ViewSource::Search {
" Results " " Results "
+1
View File
@@ -199,6 +199,7 @@ fn video(id: String, title: String, date: Option<String>) -> Video {
channel_id: None, channel_id: None,
uploader_id: None, uploader_id: None,
timestamp: None, timestamp: None,
duration: None,
publication_date: date, publication_date: date,
availability: None, availability: None,
} }
+9
View File
@@ -716,6 +716,12 @@ fn load_videos(
item.as_integer() item.as_integer()
.ok_or_else(|| invalid("Video timestamp must be an 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:?}"))), _ => return Err(invalid(format!("Unknown saved video field {key:?}"))),
}; };
fields.insert(key.into(), field); fields.insert(key.into(), field);
@@ -774,6 +780,9 @@ fn video_document<'a>(videos: impl Iterator<Item = &'a Video>) -> DocumentMut {
if let Some(timestamp) = video.timestamp { if let Some(timestamp) = video.timestamp {
table["timestamp"] = value(timestamp); table["timestamp"] = value(timestamp);
} }
if let Some(duration) = video.duration {
table["duration"] = value(duration);
}
tables.push(table); tables.push(table);
} }
document["videos"] = if tables.is_empty() { document["videos"] = if tables.is_empty() {
+1
View File
@@ -137,6 +137,7 @@ pub struct Video {
pub channel_id: Option<String>, pub channel_id: Option<String>,
pub uploader_id: Option<String>, pub uploader_id: Option<String>,
pub timestamp: Option<i64>, pub timestamp: Option<i64>,
pub duration: Option<f64>,
/// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date. /// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date.
#[serde(default, rename = "upload_date")] #[serde(default, rename = "upload_date")]
pub publication_date: Option<String>, pub publication_date: Option<String>,
+21 -5
View File
@@ -2993,7 +2993,8 @@ fn video_channel_colors_follow_category_membership_and_preserve_selection_contra
&serde_json::json!({ &serde_json::json!({
"entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({ "entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({
"id": "jNQXAC9IVRw", "title": "Video title", "channel": channel, "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::<Vec<_>>() })).collect::<Vec<_>>()
}) })
.to_string(), .to_string(),
@@ -3010,9 +3011,9 @@ fn video_channel_colors_follow_category_membership_and_preserve_selection_contra
.draw(|frame| app.draw(frame, Duration::ZERO)) .draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap(); .unwrap();
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
// One tab row, three search rows, then the table border and header. // One tab row, three search rows, then the results border and two-line entries.
for (index, (label, _, _, tagged)) in cases.iter().enumerate() { for (index, (label, _, _, tagged)) in cases.iter().enumerate() {
let y = 6 + index as u16; let y = 6 + 2 * index as u16;
let row: String = (0..120).map(|x| buffer[(x, y)].symbol()).collect(); let row: String = (0..120).map(|x| buffer[(x, y)].symbol()).collect();
let x = row.find(label).expect("channel label is visible") as u16; let x = row.find(label).expect("channel label is visible") as u16;
assert_eq!( assert_eq!(
@@ -3024,8 +3025,21 @@ fn video_channel_colors_follow_category_membership_and_preserve_selection_contra
}, },
"{label} in row {index}, cleared={cleared}" "{label} in row {index}, cleared={cleared}"
); );
assert_eq!(buffer[(3, y)].fg, Color::Reset, "title stays uncolored"); let title: String = (0..120).map(|x| buffer[(x, y - 1)].symbol()).collect();
assert_eq!(buffer[(108, y)].fg, Color::Reset, "date stays uncolored"); 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!(
buffer[(x, y)]
.modifier
.contains(ratatui::style::Modifier::DIM)
);
} }
if !cleared { if !cleared {
app.views[0].selection.select(Some(0)); app.views[0].selection.select(Some(0));
@@ -5143,6 +5157,7 @@ fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
channel_id: None, channel_id: None,
uploader_id: None, uploader_id: None,
timestamp: None, timestamp: None,
duration: None,
publication_date: None, publication_date: None,
availability: None, availability: None,
}, },
@@ -5153,6 +5168,7 @@ fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
channel_id: None, channel_id: None,
uploader_id: None, uploader_id: None,
timestamp: None, timestamp: None,
duration: None,
publication_date: None, publication_date: None,
availability: None, availability: None,
}, },
+2
View File
@@ -34,6 +34,7 @@ fn video() -> Video {
channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()), channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()),
uploader_id: Some("@jawed".into()), uploader_id: Some("@jawed".into()),
timestamp: Some(1_114_300_000), timestamp: Some(1_114_300_000),
duration: Some(19.0),
publication_date: Some("2005-04-23".into()), publication_date: Some("2005-04-23".into()),
availability: None, availability: None,
} }
@@ -66,6 +67,7 @@ fn miruro_video(episode: u32) -> Video {
channel_id: None, channel_id: None,
uploader_id: None, uploader_id: None,
timestamp: Some(1_114_300_000), timestamp: Some(1_114_300_000),
duration: None,
publication_date: Some("2005-04-23".into()), publication_date: Some("2005-04-23".into()),
availability: None, availability: None,
} }