feat: show Miruro series artwork in descriptions

This commit is contained in:
2026-10-17 17:01:33 +02:00
parent ba04cbe158
commit 9743d1993d
8 changed files with 421 additions and 147 deletions
Generated
+17
View File
@@ -491,6 +491,7 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
@@ -498,6 +499,16 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -855,6 +866,12 @@ dependencies = [
"wide",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
+1 -1
View File
@@ -6,7 +6,7 @@ description = "A terminal YouTube and Miruro browser that opens selected videos
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
image = { version = "0.25.10", default-features = false, features = ["jpeg"] }
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
serde = { version = "1.0.229", features = ["derive"] }
+6 -4
View File
@@ -6,12 +6,12 @@ A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https:
- A recent stable Rust toolchain, including Cargo, to build and install.
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client.
- `curl` on `PATH` for thumbnails.
- `curl` on `PATH` for YouTube thumbnails and Miruro cover art.
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. YouTube requires `--new-instance --url … --progress-file PATH`; Miruro additionally requires `--source-file PATH` and version-2 episode progress support.
The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player.
A terminal with native image support can display thumbnails using its graphics protocol. Other terminals fall back to colored half-block characters.
A terminal with native image support can display thumbnails and cover art using its graphics protocol. Other terminals fall back to colored half-block characters.
## Installation
@@ -58,7 +58,7 @@ Each selection opens an independent player instance without replacing existing p
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata popup, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available.
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for cover art, a bold title, dimmed metadata and a synopsis excerpt, using the same info layout as YouTube. Press `d` again for the full scrollable synopsis and returned facts; another `d` returns to info, and Esc closes either mode. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first, above the series synopsis and beside the series cover art in info mode. Listed counts do not guarantee that a supported Sun source is available.
| Key | Miruro action |
| --- | --- |
@@ -73,7 +73,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
| `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes |
| `f` | Cycle All / Unwatched / Watched in watch later |
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection; `x` also removes a saved show from the library |
| `d` | Show/hide the synopsis/metadata popup; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll; `j` / `k` change selection |
| `d` | Open info with cover art → full synopsis/metadata → info; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll the full description; `j` / `k` change selection |
| Esc | Cancel input or close help/metadata and return to the episode/title list |
| `r` | Refresh the active screen and reload configured keys |
| `?` / `q` | Keyboard help / quit |
@@ -84,6 +84,8 @@ Space checks categories for combined browsing; Enter opens their deduplicated un
Only episodes can have their watch status edited. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions.
Catalog results, series screens and saved-show lists use the returned `cover_url`; missing artwork leaves a text-only preview, and image failures do not hide metadata. Offline episode collections retain their stored text-only details. JPEG, PNG and WebP covers load, decode and resize off-thread, with native terminal graphics or proportional half-block rendering. The session image cache retains eight inactive images plus the current selection; a successful `r` refresh clears it.
Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed.
+8 -63
View File
@@ -3086,23 +3086,6 @@ impl App {
.as_deref()
.unwrap_or("Loading full description..."),
};
let mut details = if mode == PreviewMode::Description {
inner.inner(ratatui::layout::Margin::new(1, 0))
} else {
let [_, image, _, mut details] = Layout::horizontal([
Constraint::Length(1),
Constraint::Fill(3),
Constraint::Length(2),
Constraint::Fill(2),
])
.areas(inner);
let image_width = self.thumbnail.draw(frame, image);
let reclaimed = image.width - image_width;
details.x -= reclaimed;
details.width += reclaimed;
details.width = details.width.saturating_sub(2);
details
};
let metadata = [
Line::raw(video.title.as_str()).bold(),
Line::default(),
@@ -3118,6 +3101,7 @@ impl App {
.dim(),
];
if mode == PreviewMode::Description {
let details = inner.inner(ratatui::layout::Margin::new(1, 0));
let mut content = Text::from(text);
content.lines.splice(
0..0,
@@ -3142,52 +3126,13 @@ impl App {
self.description_scroll = self.description_scroll.min(self.description_max_scroll);
frame.render_widget(paragraph.scroll((self.description_scroll, 0)), details);
} else {
let metadata = Paragraph::new(Vec::from(metadata)).wrap(Wrap { trim: true });
let height = if details.is_empty() {
0
} else {
metadata
.line_count(details.width)
.min(details.height as usize) as u16
};
let mut excerpt = details;
let gap = (excerpt.height - height).min(1);
excerpt.y += height + gap;
excerpt.height -= height + gap;
details.height = height;
frame.render_widget(metadata, details);
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
let overflow = cached.is_some_and(|text| !text.is_empty())
&& !excerpt.is_empty()
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
frame.render_widget(paragraph, excerpt);
if overflow {
let buffer = frame.buffer_mut();
let y = excerpt.bottom() - 1;
let end = (excerpt.x..excerpt.right())
.rev()
.find_map(|x| {
let symbol = buffer
.cell((x, y))
.expect("excerpt is inside frame")
.symbol();
(!symbol.trim().is_empty())
.then(|| x.saturating_add(Span::raw(symbol).width() as u16))
})
.unwrap_or(excerpt.x);
let mut x = end.min(excerpt.right() - 1);
// Do not leave half of a wide character beneath the ellipsis.
if x > excerpt.x
&& Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1
{
x -= 1;
}
buffer
.cell_mut((x, y))
.unwrap()
.set_symbol("…")
.set_style(Style::new());
}
ui::info_preview(
frame,
inner,
Some(&mut self.thumbnail),
Vec::from(metadata),
text,
);
}
frame.render_widget(
block.title(loading_title(
+155 -61
View File
@@ -24,9 +24,10 @@ use serde_json::Value;
use tubular::miruro::Identity;
use crate::{
Options, config,
Options, PreviewMode, config,
keys::{Action, Context},
miruro_browser::{self, Episode, Media, Request, Response, Worker},
thumbnail::Thumbnail,
ui,
video_state::{CollectionKind, State, WatchStatus},
youtube::{PlayerLaunch, Video},
@@ -130,14 +131,14 @@ impl Screen {
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1))
});
}
fn selected_media(&self) -> Option<Media> {
fn selected_media(&self) -> Option<&Media> {
let index = self.selection.selected();
match &self.content {
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)).cloned(),
Content::Series { media, .. } => Some(media.clone()),
Content::Library { shows, .. } => index
.and_then(|i| shows.get(i))
.map(|show| show.media.clone()),
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)),
Content::Series { media, .. } => Some(media),
Content::Library { shows, .. } => {
index.and_then(|i| shows.get(i)).map(|show| &show.media)
}
_ => None,
}
}
@@ -301,7 +302,8 @@ struct Browser {
screens: Vec<Screen>,
index: usize,
input: Option<String>,
description_focus: bool,
preview: Option<PreviewMode>,
thumbnail: Thumbnail,
help: Option<u16>,
removal: Option<(CollectionKind, Vec<String>)>,
modal: Option<LibraryModal>,
@@ -394,7 +396,8 @@ impl Browser {
})],
index: 0,
input: None,
description_focus: false,
preview: None,
thumbnail: Thumbnail::new(),
help: None,
removal: None,
modal: None,
@@ -433,7 +436,7 @@ impl Browser {
self.screens.truncate(self.index + 1);
self.screens.push(screen);
self.index += 1;
self.description_focus = false;
self.preview = None;
self.status.clear();
}
fn history(&mut self, forward: bool) {
@@ -447,7 +450,7 @@ impl Browser {
}
self.invalidate();
self.index = index;
self.description_focus = false;
self.preview = None;
self.status.clear();
self.rebuild_collections();
if matches!(&self.screen().content, Content::Series { data: None, .. }) {
@@ -469,7 +472,7 @@ impl Browser {
if let Content::Catalog { query, .. } = &self.screen().content {
self.input = Some(query.clone());
}
self.description_focus = false;
self.preview = None;
}
fn search(&mut self) {
let Content::Catalog { query, .. } = &self.screen().content else {
@@ -517,7 +520,7 @@ impl Browser {
}
}
fn open(&mut self) {
if let Some(media) = self.screen().selected_media()
if let Some(media) = self.screen().selected_media().cloned()
&& !matches!(self.screen().content, Content::Series { .. })
{
self.push(Screen::new(Content::Series { media, data: None }));
@@ -671,7 +674,7 @@ impl Browser {
.collect()
}
fn tags(&mut self) {
let Some(media) = self.screen().selected_media() else {
let Some(media) = self.screen().selected_media().cloned() else {
return;
};
let checked = self
@@ -684,7 +687,7 @@ impl Browser {
let names: Vec<_> = self.categories().into_iter().collect();
let mut picker = Picker::default();
picker.refilter(&names);
self.description_focus = false;
self.preview = None;
self.modal = Some(LibraryModal::Tags {
media,
checked,
@@ -699,7 +702,7 @@ impl Browser {
names.extend(self.categories());
let mut picker = Picker::default();
picker.refilter(&names);
self.description_focus = false;
self.preview = None;
self.modal = Some(LibraryModal::Categories {
names,
checked: BTreeSet::new(),
@@ -946,7 +949,8 @@ impl Browser {
fn remove(&mut self, bulk: bool) {
if matches!(self.screen().content, Content::Library { .. }) {
if !bulk && let Some(media) = self.screen().selected_media() {
match self.state.remove_show(&media.id) {
let id = media.id.clone();
match self.state.remove_show(&id) {
Ok(()) => {
self.rebuild_library();
self.status = "Removed saved show; episode state unchanged.".into();
@@ -974,7 +978,7 @@ impl Browser {
self.invalidate();
if bulk {
self.removal = Some((kind, ids));
self.description_focus = false;
self.preview = None;
} else {
self.remove_ids(kind, ids);
}
@@ -1000,7 +1004,10 @@ impl Browser {
fn refresh(&mut self) {
self.invalidate();
match config::load(&self.config_path) {
Ok(config) => self.config = config,
Ok(config) => {
self.config = config;
self.thumbnail.clear_cache();
}
Err(error) => {
self.fail(error);
return;
@@ -1043,15 +1050,17 @@ impl Browser {
self.screen().content,
Content::Collection { .. } | Content::Library { .. }
) {
if self.description_focus {
Context::CollectionDescription
} else {
Context::CollectionNormal
match self.preview {
Some(PreviewMode::Info) => Context::CollectionInfo,
Some(PreviewMode::Description) => Context::CollectionDescription,
None => Context::CollectionNormal,
}
} else if self.description_focus {
Context::Description
} else {
Context::Normal
match self.preview {
Some(PreviewMode::Info) => Context::Info,
Some(PreviewMode::Description) => Context::Description,
None => Context::Normal,
}
}
}
fn select(&mut self, action: Action) {
@@ -1176,10 +1185,9 @@ impl Browser {
| Action::VideoFirst
| Action::VideoLast),
) => self.select(action),
Some(Action::InfoOverview | Action::InfoDescription) => {
self.description_focus = !self.description_focus
}
Some(Action::InfoHide) => self.description_focus = false,
Some(Action::InfoOverview) => self.preview = Some(PreviewMode::Info),
Some(Action::InfoDescription) => self.preview = Some(PreviewMode::Description),
Some(Action::InfoHide) => self.preview = None,
Some(Action::DescriptionNext) => {
self.screen_mut().description_scroll =
self.screen().description_scroll.saturating_add(1)
@@ -1297,7 +1305,10 @@ impl Browser {
(Action::WatchLaterBrowse, "Watch later episodes"),
(Action::SavedVideoBrowse, "Saved episodes"),
(Action::WatchingBrowse, "Currently watching episodes"),
(Action::InfoOverview, "Show/hide metadata"),
(
Action::InfoOverview,
"Show series/episode info with cover art",
),
(Action::Help, "Keyboard help"),
(Action::Quit, "Exit"),
(Action::Interrupt, "Exit immediately"),
@@ -1312,10 +1323,11 @@ impl Browser {
"Remove selected saved show only",
));
}
if self.config.keys.label(Action::InfoDescription)
!= self.config.keys.label(Action::InfoOverview)
if self.preview.is_some()
|| self.config.keys.label(Action::InfoDescription)
!= self.config.keys.label(Action::InfoOverview)
{
bindings.push((Action::InfoDescription, "Show/hide metadata"));
bindings.push((Action::InfoDescription, "Show full synopsis/metadata"));
}
if matches!(
self.screen().content,
@@ -1340,9 +1352,11 @@ impl Browser {
bindings.push((Action::WatchLaterFilter, "Cycle All / Unwatched / Watched"));
}
}
if self.description_focus {
if self.preview.is_some() {
bindings.push((Action::InfoHide, "Close preview"));
}
if self.preview == Some(PreviewMode::Description) {
bindings.extend([
(Action::InfoHide, "Close metadata"),
(Action::DescriptionNext, "Scroll metadata down"),
(Action::DescriptionPrevious, "Scroll metadata up"),
(Action::DescriptionPageNext, "Metadata page down"),
@@ -1364,11 +1378,12 @@ impl Browser {
&& self.help.is_none()
&& self.input.is_none()
&& self.removal.is_none()
&& !self.description_focus
&& self.preview.is_none()
{
let mut actions = vec![
(Action::Help, "help"),
(Action::SearchEdit, "search"),
(Action::InfoOverview, "info"),
(
Action::VideoPlay,
if matches!(
@@ -1425,10 +1440,20 @@ impl Browser {
(Action::DeleteWord, "delete word"),
],
)
} else if self.description_focus {
} else if self.preview == Some(PreviewMode::Info) {
(
"INFO",
&[
(Action::InfoDescription, "description"),
(Action::InfoHide, "close"),
(Action::Help, "help"),
],
)
} else if self.preview == Some(PreviewMode::Description) {
(
"DESCRIPTION",
&[
(Action::InfoOverview, "info"),
(Action::InfoHide, "close"),
(Action::Help, "help"),
(Action::WatchStatusWatching, "mark watching"),
@@ -1676,29 +1701,92 @@ impl Browser {
);
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
let popup = crate::preview_area(area);
if self.description_focus {
let block = Block::bordered()
.title(" Description ")
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0));
let mut metadata = ratatui::text::Text::from(self.screen().description.as_str());
if let Some(key) = self.screen().selected_key() {
metadata.lines.push(Line::from(self.markers(key)));
if let Some(mode) = self.preview {
let cover = self.screens[self.index]
.selected_media()
.and_then(|media| media.metadata.get("cover_url"))
.and_then(Value::as_str)
.filter(|url| !url.trim().is_empty());
if mode == PreviewMode::Info {
if let Some(url) = cover {
self.thumbnail.select_url(url);
} else {
self.thumbnail.cancel();
}
}
let paragraph = Paragraph::new(metadata).wrap(Wrap { trim: true });
let max_scroll = if inner.is_empty() {
0
} else {
paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16
};
let scroll = self.screen().description_scroll.min(max_scroll);
frame.render_widget(Clear, popup);
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
let block = Block::bordered()
.title(crate::loading_title(
if mode == PreviewMode::Info {
" Info "
} else {
" Description "
},
"",
(mode == PreviewMode::Info && self.thumbnail.is_loading())
.then(|| ui::spinner(elapsed)),
))
.border_style(ui::ACCENT);
let inner = block.inner(popup);
let screen = &self.screens[self.index];
let row = screen.selection.selected().and_then(|i| screen.rows.get(i));
let mut metadata = Vec::new();
let mut text = screen.description.as_str();
if let Some(row) = row {
metadata.extend([
Line::from(row.title.as_str()).bold(),
Line::default(),
Line::from(row.details.as_str()).dim(),
]);
text = text
.strip_prefix(&row.title)
.unwrap_or(text)
.trim_start_matches('\n');
text = text
.strip_prefix(&row.details)
.unwrap_or(text)
.trim_start_matches('\n');
}
if let Some(key) = screen.selected_key() {
let markers = self.markers(key);
if !markers.is_empty() {
metadata.push(Line::from(markers).dim());
}
}
if mode == PreviewMode::Info {
ui::info_preview(
frame,
inner,
cover.map(|_| &mut self.thumbnail),
metadata,
text,
);
} else {
let inner = inner.inner(Margin::new(1, 0));
let mut content = ratatui::text::Text::from(text);
content.lines.splice(
0..0,
[Line::default()]
.into_iter()
.chain(metadata)
.chain([Line::default()]),
);
let paragraph = Paragraph::new(content).wrap(Wrap { trim: false });
let max_scroll = if inner.is_empty() {
0
} else {
paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16
};
let scroll = screen.description_scroll.min(max_scroll);
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
self.screen_mut().description_scroll = scroll;
}
frame.render_widget(block, popup);
self.screen_mut().description_scroll = scroll;
} else {
self.thumbnail.cancel();
}
if let Some((kind, ids)) = &self.removal {
frame.render_widget(Clear, popup);
@@ -2007,6 +2095,7 @@ pub fn run(
SetCursorStyle::SteadyBlock
)?;
let mut browser = Browser::new(options, config_path, config, state);
browser.thumbnail.query_terminal();
let started = Instant::now();
loop {
browser.update();
@@ -2140,6 +2229,9 @@ mod tests {
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::Info);
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::Description);
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
@@ -2164,8 +2256,10 @@ mod tests {
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
assert!(popup_text(&terminal).contains("TAIL"));
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::Info);
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!browser.description_focus);
assert!(browser.preview.is_none());
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = 0;
}
@@ -2290,7 +2384,7 @@ mod tests {
.save(CollectionKind::Watching, &youtube)
.unwrap();
browser.save(CollectionKind::WatchLater);
browser.description_focus = true;
browser.preview = Some(PreviewMode::Info);
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert!(
browser
@@ -2298,7 +2392,7 @@ mod tests {
.contains(CollectionKind::Watching, &episode.id)
);
assert!(!browser.state.is_watched(&episode.id));
browser.description_focus = false;
browser.preview = None;
browser.collection(CollectionKind::Watching);
browser.open();
assert!(browser.pending.is_some());
+128 -15
View File
@@ -4,7 +4,7 @@ use std::{
thread,
};
use image::{ImageFormat, ImageReader, Limits};
use image::{ImageReader, Limits};
use ratatui::{
Frame,
layout::{Alignment, Rect, Size},
@@ -30,6 +30,7 @@ struct ThumbnailImage {
pub struct Thumbnail {
picker: Picker,
selected: Option<String>,
aspect: (u32, u32),
fetch: Option<ThumbnailJob>,
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
image: Option<ThumbnailImage>,
@@ -43,6 +44,7 @@ impl Thumbnail {
Self {
picker: Picker::halfblocks(),
selected: None,
aspect: (4, 3),
fetch: None,
decoder: None,
image: None,
@@ -61,6 +63,19 @@ impl Thumbnail {
}
pub fn select(&mut self, id: &str) {
self.select_with(id, (4, 3), ThumbnailJob::start);
}
pub fn select_url(&mut self, url: &str) {
self.select_with(url, (2, 3), ThumbnailJob::from_url);
}
fn select_with(
&mut self,
id: &str,
aspect: (u32, u32),
start: fn(&str) -> std::io::Result<ThumbnailJob>,
) {
if self.selected.as_deref() == Some(id) {
return;
}
@@ -72,11 +87,12 @@ impl Thumbnail {
.and_then(|index| self.cached.remove(index));
self.cancel();
self.selected = Some(id.to_owned());
self.aspect = aspect;
if let Some(image) = cached {
self.image = Some(image);
return;
}
match ThumbnailJob::start(id) {
match start(id) {
Ok(job) => self.fetch = Some(job),
Err(error) => self.error = Some(format!("Could not load thumbnail: {error}")),
}
@@ -118,13 +134,17 @@ impl Thumbnail {
if area.width == 0 || area.height == 0 || self.selected.is_none() {
return area.width;
}
let slot = left_aligned(area, thumbnail_size(area, self.picker.font_size()));
let slot = left_aligned(
area,
thumbnail_size(area, self.picker.font_size(), self.aspect),
);
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
if let Some(image) = self.image.as_mut() {
let resize = Resize::Scale(Some(FilterType::Triangle));
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
let image_area = left_aligned(area, size);
let bounds = if self.aspect == (4, 3) { area } else { slot };
if let Some(size) = image.protocol.size_for(resize.clone(), bounds.into()) {
let image_area = left_aligned(bounds, size);
frame.render_stateful_widget(
StatefulImage::new().resize(resize),
image_area,
@@ -158,7 +178,7 @@ impl Thumbnail {
match result {
Ok(bytes) => {
let picker = self.picker.clone();
let id = self.selected.clone().expect("A fetch has a selected video");
let id = self.selected.clone().expect("A fetch has a selected image");
let (tx, rx) = mpsc::channel();
match thread::Builder::new()
.name("thumbnail-decode".into())
@@ -243,7 +263,9 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
if bytes.len() > 2 * 1024 * 1024 {
return Err("Thumbnail exceeds the 2 MiB download limit.".into());
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg);
let mut reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|error| format!("Could not identify thumbnail format: {error}"))?;
let mut limits = Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
@@ -251,21 +273,28 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
reader.limits(limits);
reader
.decode()
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
.map_err(|error| format!("Could not decode thumbnail: {error}"))
}
fn thumbnail_size(area: Rect, font: FontSize) -> Size {
fn thumbnail_size(area: Rect, font: FontSize, aspect: (u32, u32)) -> Size {
if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 {
return Size::default();
}
// HQ thumbnails are 480x360. Match Scale's nearest-pixel rounding, then
// round up to cells without allocating a placeholder image.
// Match Scale's nearest-pixel rounding, then round up to cells without
// allocating a placeholder image. YouTube is 4:3; series covers reserve 2:3.
let (aspect_width, aspect_height) = (u64::from(aspect.0), u64::from(aspect.1));
let width = u64::from(area.width) * u64::from(font.width);
let height = u64::from(area.height) * u64::from(font.height);
let (width, height) = if width * 3 <= height * 4 {
(width, ((width * 3 + 2) / 4).max(1))
let (width, height) = if width * aspect_height <= height * aspect_width {
(
width,
((width * aspect_height + aspect_width / 2) / aspect_width).max(1),
)
} else {
(((height * 4 + 1) / 3).max(1), height)
(
((height * aspect_width + aspect_height / 2) / aspect_height).max(1),
height,
)
};
Size::new(
width
@@ -362,10 +391,94 @@ mod tests {
assert_eq!(loading_width, error_width);
assert!(thumbnail.error.is_some());
}
#[test]
fn rejects_corrupt_and_oversized_jpeg_input() {
fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() {
use ratatui::{Terminal, backend::TestBackend};
use std::time::Duration;
let mut thumbnail = Thumbnail::new();
let url = "https://example.test/cover.png";
thumbnail.selected = Some(url.into());
thumbnail.aspect = (2, 3);
let area = Rect::new(2, 2, 80, 8);
let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3));
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
let mut loading_width = 0;
terminal
.draw(|frame| loading_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, slot.width);
// The artwork need not exactly match the placeholder's 2:3 aspect.
let protocol = thumbnail
.picker
.new_resize_protocol(image::DynamicImage::new_rgb8(60, 100));
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
let (replies_tx, replies_rx) = mpsc::channel();
thumbnail.image = Some(ThumbnailImage {
id: url.into(),
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
replies: replies_rx,
complete: false,
});
terminal
.draw(|frame| {
assert_eq!(thumbnail.draw(frame, area), loading_width);
})
.unwrap();
let response = requests_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
.resize_encode()
.unwrap();
replies_tx.send(Ok(response)).unwrap();
terminal
.draw(|frame| {
assert_eq!(thumbnail.draw(frame, area), loading_width);
})
.unwrap();
assert!(!thumbnail.is_loading());
let size = thumbnail
.image
.as_ref()
.unwrap()
.protocol
.size_for(Resize::Scale(None), slot)
.unwrap();
assert!(size.width <= slot.width && size.height <= slot.height);
let font = thumbnail.picker.font_size();
let width = u32::from(size.width) * u32::from(font.width);
let height = u32::from(size.height) * u32::from(font.height);
assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height));
thumbnail.cancel();
thumbnail.select_url(url);
assert!(thumbnail.fetch.is_none());
assert_eq!(thumbnail.selected.as_deref(), Some(url));
terminal
.draw(|frame| {
assert_eq!(thumbnail.draw(frame, area), loading_width);
})
.unwrap();
thumbnail.clear_cache();
assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty());
}
#[test]
fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() {
assert!(decode(b"not a JPEG".to_vec()).is_err());
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
for format in [
image::ImageFormat::Jpeg,
image::ImageFormat::Png,
image::ImageFormat::WebP,
] {
let mut bytes = Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2, 3)
.write_to(&mut bytes, format)
.unwrap();
let image = decode(bytes.into_inner()).unwrap();
assert_eq!((image.width(), image.height()), (2, 3));
}
}
#[test]
+71 -2
View File
@@ -4,8 +4,8 @@ use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::Line,
widgets::{Block, Paragraph},
text::{Line, Span},
widgets::{Block, Paragraph, Wrap},
};
pub const ACCENT: Style = Style::new().fg(Color::Cyan);
@@ -50,3 +50,72 @@ pub fn spinner(elapsed: Duration) -> &'static str {
const FRAMES: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
FRAMES[(elapsed.as_millis() / 100 % FRAMES.len() as u128) as usize]
}
pub fn info_preview(
frame: &mut Frame,
area: Rect,
thumbnail: Option<&mut crate::thumbnail::Thumbnail>,
metadata: Vec<Line<'_>>,
text: &str,
) {
let mut details = if let Some(thumbnail) = thumbnail {
let [_, image, _, mut details] = Layout::horizontal([
Constraint::Length(1),
Constraint::Fill(3),
Constraint::Length(2),
Constraint::Fill(2),
])
.areas(area);
let reclaimed = image.width - thumbnail.draw(frame, image);
details.x -= reclaimed;
details.width += reclaimed;
details.width = details.width.saturating_sub(2);
details
} else {
area.inner(ratatui::layout::Margin::new(1, 0))
};
let metadata = Paragraph::new(metadata).wrap(Wrap { trim: true });
let height = if details.is_empty() {
0
} else {
metadata
.line_count(details.width)
.min(details.height as usize) as u16
};
let mut excerpt = details;
let gap = (excerpt.height - height).min(1);
excerpt.y += height + gap;
excerpt.height -= height + gap;
details.height = height;
frame.render_widget(metadata, details);
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
let overflow = !text.is_empty()
&& !excerpt.is_empty()
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
frame.render_widget(paragraph, excerpt);
if overflow {
let buffer = frame.buffer_mut();
let y = excerpt.bottom() - 1;
let end = (excerpt.x..excerpt.right())
.rev()
.find_map(|x| {
let symbol = buffer
.cell((x, y))
.expect("excerpt is inside frame")
.symbol();
(!symbol.trim().is_empty())
.then(|| x.saturating_add(Span::raw(symbol).width() as u16))
})
.unwrap_or(excerpt.x);
let mut x = end.min(excerpt.right() - 1);
// Do not leave half of a wide character beneath the ellipsis.
if x > excerpt.x && Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1 {
x -= 1;
}
buffer
.cell_mut((x, y))
.unwrap()
.set_symbol("…")
.set_style(Style::new());
}
}
+35 -1
View File
@@ -679,6 +679,25 @@ impl ThumbnailJob {
"Invalid YouTube video ID",
));
}
Self::from_url(&format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))
}
pub fn from_url(url: &str) -> io::Result<Self> {
let uri: ureq::http::Uri = url
.parse()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid artwork URL"))?;
if uri.scheme_str() != Some("https")
|| uri.host().is_none_or(str::is_empty)
|| uri
.authority()
.is_some_and(|authority| authority.as_str().contains('@'))
|| url.chars().any(char::is_control)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Artwork URLs must be absolute HTTPS without credentials",
));
}
let mut child = isolated_command(OsStr::new("curl"))
.args([
"--disable",
@@ -686,6 +705,7 @@ impl ThumbnailJob {
"--show-error",
"--fail",
"--location",
"--globoff",
"--proto",
"=https",
"--proto-redir",
@@ -697,7 +717,7 @@ impl ThumbnailJob {
"--max-filesize",
"2097152", // 2 MiB; read_thumbnail also enforces the limit.
])
.arg(format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -1570,6 +1590,20 @@ mod tests {
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
for url in [
"http://example.test/cover.jpg",
"file:///tmp/cover.jpg",
"ftp://example.test/cover.jpg",
"//example.test/cover.jpg",
"https://",
"https://user:secret@example.test/cover.jpg",
"https://example.test/\ncover.jpg",
] {
assert!(matches!(
ThumbnailJob::from_url(url),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
}
#[test]