Terminal reader: play with mpv, mark read on move, folders and pins (#439, #440, #441)

p hands the item's downloaded file (/media/{id}) to mpv in its own window, with the token as a
header. Moving onto an item with j or k marks it read, as selecting one on the page does. The
feed list follows the page's order: pinned first, a subscribed OPML a folder that Space opens,
its unread the sum of its feeds', and P pins or unpins. scripts/build-tui.sh cross-builds the
binary for Linux and macOS under the same cap as the image builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
This commit is contained in:
2026-11-11 23:04:02 +00:00
parent 3a3900e959
commit 75d099474c
5 changed files with 265 additions and 14 deletions

View File

@@ -12,3 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).
- `p` plays the item's file in mpv (#439).
- Moving onto an item with `j` or `k` marks it read, as on the page (#441).
- Feeds are listed as on the page: a subscribed OPML is a folder `Space` opens, and pinned feeds are at the top; `P` pins and unpins (#440).

View File

@@ -15,13 +15,18 @@ IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
| key | does |
|---|---|
| `j` `k`, arrows | move, or scroll the item's text |
| `Enter`, `l`, `Tab` | open the feed or the item; opening an item marks it read |
| `Enter`, `l`, `Tab` | open the feed or the item |
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
| `Space`, `PgUp` `PgDn` | page through the item's text |
| `Space` | on the feeds, open or close a folder: a subscribed OPML, its feeds inside |
| `P` | pin the feed or folder to the top of the list, or unpin it |
| `p` | play the item's file in [mpv](https://mpv.io), in a window of its own |
| `m` | mark the item read or unread |
| `f` | flag or unflag it |
| `u` | unread items only, or every item (unread only to start) |
| `r` | reload |
| `q` | quit |
It shows a feed's newest 200 items.
An item is marked read when you move onto it or open it, as on the page. It shows a feed's newest
200 items. Playing needs `mpv` on the path (`brew install mpv` on a Mac),
and where you stop is not saved to iPX as it is on the page.

View File

@@ -11,6 +11,7 @@ use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use reqwest::Url;
use serde::Deserialize;
use std::collections::HashSet;
use std::time::Duration;
#[derive(Parser)]
@@ -34,6 +35,29 @@ struct Feed {
/// The OPML it came from.
group: Option<String>,
hide_from_all: bool,
pinned: bool,
}
/// The feed list as the page draws it (feeds.ts renderFeeds), as indexes into `feeds` and
/// whether each is inside a folder: pinned rows first, a subscribed OPML a folder with its feeds
/// under it when open (those with unread first), and a pinned feed lifted out of its folder.
fn order(feeds: &[Feed], open: &HashSet<String>) -> Vec<(usize, bool)> {
let listed = |id: &str| feeds.iter().any(|f| f.id == id);
let mut tops: Vec<usize> =
(0..feeds.len()).filter(|&i| feeds[i].pinned || !feeds[i].group.as_deref().is_some_and(listed)).collect();
tops.sort_by_key(|&i| !feeds[i].pinned);
let mut rows = vec![];
for t in tops {
rows.push((t, false));
if open.contains(&feeds[t].id) {
let mut inside: Vec<usize> = (0..feeds.len())
.filter(|&i| !feeds[i].pinned && feeds[i].group.as_deref() == Some(feeds[t].id.as_str()))
.collect();
inside.sort_by_key(|&i| feeds[i].unread == 0);
rows.extend(inside.into_iter().map(|i| (i, true)));
}
}
rows
}
/// The part of db.rs's EntryRow this needs.
@@ -47,6 +71,20 @@ struct Entry {
description: Option<String>,
read: bool,
flagged: bool,
enclosures: Vec<Enclosure>,
}
/// The part of db.rs's EncRow this needs: its id is its address, `/media/{id}`, which is there
/// only once it is downloaded (`path`); one skipped or still to come answers 404.
#[derive(Deserialize)]
struct Enclosure {
id: i64,
path: Option<String>,
}
/// What `p` plays: the item's first file iPX has.
fn playable(e: &Entry) -> Option<&Enclosure> {
e.enclosures.iter().find(|x| x.path.is_some())
}
#[derive(Deserialize)]
@@ -77,6 +115,29 @@ impl Api {
Ok(res)
}
/// Plays a file in mpv, in a window of its own so the terminal stays the reader's, with the
/// token in a header as every other request has it.
// shortcut: the token is on mpv's command line, which `ps` shows anyone on this machine;
// pass it through a private mpv config file if the reader is used on a shared one.
fn play(&self, enc: &Enclosure) -> Result<()> {
let url = self.url(&["media", &enc.id.to_string()]);
std::process::Command::new("mpv")
.arg("--force-window=yes")
.arg(format!("--http-header-fields=Authorization: Bearer {}", self.token))
.arg(url.as_str())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("could not start mpv; is it installed?")?;
Ok(())
}
fn set_pinned(&self, feed: &str, on: bool) -> Result<()> {
let req = self.http.patch(self.url(&["api", "feeds", feed])).json(&serde_json::json!({ "pinned": on }));
self.send(req).map(drop)
}
fn feeds(&self) -> Result<Vec<Feed>> {
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
}
@@ -109,8 +170,11 @@ enum Pane {
struct App {
api: Api,
/// Your feeds, after All Subscriptions, which is index 0 of `feed_list`.
feeds: Vec<Feed>,
/// What the feed list shows, after All Subscriptions, which is index 0 of `feed_list`.
rows: Vec<(usize, bool)>,
/// The folders open; they start closed, as on the page, since one can hold dozens of feeds.
open: HashSet<String>,
feed_list: ListState,
entries: Vec<Entry>,
total: i64,
@@ -122,9 +186,29 @@ struct App {
}
impl App {
fn feed_id(&self) -> Option<&str> {
fn feed(&self) -> Option<&Feed> {
let i = self.feed_list.selected()?;
self.feeds.get(i.checked_sub(1)?).map(|f| f.id.as_str())
self.rows.get(i.checked_sub(1)?).map(|&(f, _)| &self.feeds[f])
}
fn feed_id(&self) -> Option<&str> {
self.feed().map(|f| f.id.as_str())
}
/// The feed list again after the feeds or the open folders change, on the same feed, or on
/// its folder when that has just closed over it.
fn arrange(&mut self) {
let was = self.feed().map(|f| (f.id.clone(), f.group.clone()));
self.rows = order(&self.feeds, &self.open);
let at = |id: &str| self.rows.iter().position(|&(f, _)| self.feeds[f].id == id).map(|i| i + 1);
let row = was.and_then(|(id, group)| at(&id).or_else(|| group.and_then(|g| at(&g))));
self.feed_list.select(Some(row.unwrap_or(0)));
}
/// The unread count a row shows: a folder holds no items itself, so it is what is inside.
fn unread(&self, f: &Feed) -> i64 {
let inside: Vec<_> = self.feeds.iter().filter(|c| c.group.as_deref() == Some(f.id.as_str())).collect();
if inside.is_empty() { f.unread } else { inside.iter().map(|c| c.unread).sum() }
}
fn entry(&self) -> Option<&Entry> {
@@ -139,6 +223,7 @@ impl App {
fn load_feeds(&mut self) {
if let Some(feeds) = self.attempt(self.api.feeds()) {
self.feeds = feeds;
self.arrange();
self.status.clear();
}
}
@@ -171,13 +256,18 @@ impl App {
}
}
/// Selecting an item is reading it, as on the page (items.ts markRead), j and k included.
fn mark_read(&mut self) {
if self.entry().is_some_and(|e| !e.read) {
self.flag("read", |_| true);
}
}
/// Opens the selected item, which marks it read, as opening one on the page does.
fn open(&mut self) {
self.scroll = 0;
self.pane = Pane::Text;
if self.entry().is_some_and(|e| !e.read) {
self.flag("read", |_| true);
}
self.mark_read();
}
fn step(&mut self, down: bool) {
@@ -196,6 +286,7 @@ impl App {
}
if self.pane == Pane::Items {
self.scroll = 0;
self.mark_read();
}
}
@@ -209,6 +300,22 @@ impl App {
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
self.scroll = self.scroll.saturating_add(10)
}
KeyCode::Char(' ') if self.pane == Pane::Feeds => {
if let Some(id) = self.feed_id().map(str::to_owned) {
if !self.open.remove(&id) {
self.open.insert(id);
}
self.arrange();
}
}
KeyCode::Char('P') => {
let Some(f) = self.feed() else { return true };
let (id, on) = (f.id.clone(), !f.pinned);
if self.attempt(self.api.set_pinned(&id, on)).is_some() {
self.load_feeds();
self.status = if on { "pinned to the top" } else { "unpinned" }.into();
}
}
KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane {
Pane::Feeds => {
@@ -224,6 +331,18 @@ impl App {
_ => Pane::Feeds,
}
}
KeyCode::Char('p') => {
let Some(e) = self.entry() else { return true };
match playable(e).map(|enc| self.api.play(enc)) {
Some(r) => {
if self.attempt(r).is_some() {
self.status = "playing in mpv".into();
}
}
None if e.enclosures.is_empty() => self.status = "this item has no file to play".into(),
None => self.status = "iPX has not downloaded this item's file".into(),
}
}
KeyCode::Char('m') => self.flag("read", |e| !e.read),
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
KeyCode::Char('u') => {
@@ -258,10 +377,23 @@ impl App {
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
.map(|f| f.unread)
.sum();
let feeds = std::iter::once(format!("All Subscriptions ({all})")).chain(self.feeds.iter().map(|f| {
let name = f.title.as_deref().unwrap_or(&f.url);
if f.unread > 0 { format!("{name} ({})", f.unread) } else { name.to_string() }
}));
let feeds =
std::iter::once(format!("All Subscriptions ({all})")).chain(self.rows.iter().map(|&(i, inside)| {
let f = &self.feeds[i];
let folder = self.feeds.iter().any(|c| c.group.as_deref() == Some(f.id.as_str()));
let lead = match (inside, folder, self.open.contains(&f.id)) {
(true, ..) => " ",
(_, true, true) => "▾ ",
(_, true, false) => "▸ ",
_ => " ",
};
let pin = if f.pinned && !inside { "↑ " } else { "" };
let name = f.title.as_deref().unwrap_or(&f.url);
match self.unread(f) {
0 => format!("{lead}{pin}{name}"),
n => format!("{lead}{pin}{name} ({n})"),
}
}));
let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked);
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
@@ -281,6 +413,9 @@ impl App {
if let Some(link) = &e.link {
lines.push(Line::from(link.clone()).dim());
}
if playable(e).is_some() {
lines.push(Line::from("▶ p to play").dim());
}
lines.push(Line::default());
lines
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
@@ -291,7 +426,7 @@ impl App {
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
let help = "j/k move enter open h back m read f flag u unread only r reload q quit";
let help = "j/k move enter open h back space folder P pin p play m read f flag u unread only r reload q quit";
let foot_text = if self.status.is_empty() { help } else { &self.status };
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
}
@@ -419,6 +554,8 @@ fn main() -> Result<()> {
let mut app = App {
api,
feeds,
rows: vec![],
open: HashSet::new(),
feed_list: ListState::default().with_selected(Some(0)),
entries: vec![],
total: 0,
@@ -428,6 +565,7 @@ fn main() -> Result<()> {
unread_only: true,
status: String::new(),
};
app.arrange();
app.load_entries();
let mut terminal = ratatui::init();
let result = run(&mut terminal, &mut app);
@@ -448,4 +586,56 @@ mod tests {
assert_eq!(text_of("a <i>b</i> c"), "a b c");
assert_eq!(text_of("<b>x</b> <i>y</i>"), "x y");
}
#[test]
fn feeds_in_the_pages_order() {
let feed = |id: &str, group: Option<&str>, pinned: bool, unread: i64| Feed {
id: id.into(),
url: id.into(),
title: None,
unread,
group: group.map(Into::into),
hide_from_all: false,
pinned,
};
let feeds = [
feed("a", None, false, 0),
feed("opml", None, false, 0),
feed("in-read", Some("opml"), false, 0),
feed("in-unread", Some("opml"), false, 3),
feed("in-pinned", Some("opml"), true, 0),
feed("b", None, true, 0),
feed("lost", Some("gone"), false, 0),
];
let ids = |open: &[&str]| -> Vec<(&str, bool)> {
let open = open.iter().map(|s| s.to_string()).collect();
order(&feeds, &open).into_iter().map(|(i, inside)| (feeds[i].id.as_str(), inside)).collect()
};
// Closed, a folder hides its feeds; a pinned one is out of it, at the top; a feed whose
// folder is not listed stands on its own.
assert_eq!(ids(&[]), [("in-pinned", false), ("b", false), ("a", false), ("opml", false), ("lost", false)]);
assert_eq!(
ids(&["opml"]),
[
("in-pinned", false),
("b", false),
("a", false),
("opml", false),
("in-unread", true),
("in-read", true),
("lost", false),
]
);
}
#[test]
fn plays_the_first_downloaded_file() {
let e: Entry = serde_json::from_value(serde_json::json!({
"guid": "g", "feed_id": "f", "title": null, "link": null, "published": null,
"description": null, "read": false, "flagged": false,
"enclosures": [{"id": 1, "path": null}, {"id": 2, "path": "a.mp3"}, {"id": 3, "path": "b.mp3"}],
}))
.unwrap();
assert_eq!(playable(&e).map(|x| x.id), Some(2));
}
}