From 75d099474c9bdbee9eef378d67a3cefd7f20d8be Mon Sep 17 00:00:00 2001 From: rays Date: Wed, 11 Nov 2026 23:04:02 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N --- .claude/settings.json | 3 +- crates/tui/CHANGELOG.md | 3 + crates/tui/README.md | 9 +- crates/tui/src/main.rs | 212 +++++++++++++++++++++++++++++++++++++--- scripts/build-tui.sh | 52 ++++++++++ 5 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 scripts/build-tui.sh diff --git a/.claude/settings.json b/.claude/settings.json index 5cd38a8..c6393d7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,7 +3,8 @@ "allow": [ "Bash(ffprobe -v error *)", "Bash(env)", - "mcp__outline__read_document" + "mcp__outline__read_document", + "Bash(sh scripts/build-tui.sh)" ] }, "hooks": { diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 153a9ce..55df6a3 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -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). diff --git a/crates/tui/README.md b/crates/tui/README.md index 7e6c942..9c21f9e 100644 --- a/crates/tui/README.md +++ b/crates/tui/README.md @@ -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. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 4f9f97d..bbc5379 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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, 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) -> Vec<(usize, bool)> { + let listed = |id: &str| feeds.iter().any(|f| f.id == id); + let mut tops: Vec = + (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 = (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, read: bool, flagged: bool, + enclosures: Vec, +} + +/// 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, +} + +/// 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> { 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, + /// 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, feed_list: ListState, entries: Vec, 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 b c"), "a b c"); assert_eq!(text_of("x y"), "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)); + } } diff --git a/scripts/build-tui.sh b/scripts/build-tui.sh new file mode 100644 index 0000000..32ae219 --- /dev/null +++ b/scripts/build-tui.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# The terminal reader, `ipx`, for Linux amd64 and Apple silicon Macs, into target/tui/. +# +# sh scripts/build-tui.sh +# +# Cross-compiled with cargo-zigbuild, whose image carries zig as the linker and macOS's SDK, so no +# Mac is needed. The Linux build is musl and static: it runs on any distribution. A release build +# of every dependency is the kind that took Tower down on 2026-11-04, so it runs in a container +# under the same cap as scripts/build-image.sh's builder (6 GB, six threads); if the cap kills it, +# say so and leave the cap be. +set -eu + +MEM=6g +CPUS=0-5 +NEED_KB=$((9 * 1024 * 1024)) +MAX_LOAD=12 +IMAGE=ghcr.io/rust-cross/cargo-zigbuild:0.24.1 +TARGETS="x86_64-unknown-linux-musl aarch64-apple-darwin" +# Tower's paths, since the Docker daemon is Tower's (see scripts/coverage.sh). +SRC=/mnt/user/vscode/ipx +TARGET=/mnt/fast/vscode/ipx/target-real + +cd "$(dirname "$0")/.." + +avail=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo) +load=$(cut -d' ' -f1 /proc/loadavg) +echo "MemAvailable $((avail / 1024)) MB, load $load" +if [ "$avail" -lt "$NEED_KB" ]; then + echo "refusing: under $((NEED_KB / 1024 / 1024)) GB available. Wait, or find what is using it." >&2 + exit 1 +fi +if awk -v l="$load" -v m="$MAX_LOAD" 'BEGIN { exit !(l > m) }'; then + echo "refusing: load over $MAX_LOAD. Wait for it to come down." >&2 + exit 1 +fi + +# The source read only; the registry and the compiled crates are kept between runs. +docker run --rm --memory "$MEM" --memory-swap "$MEM" --cpuset-cpus "$CPUS" \ + -v "$SRC:/work:ro" -v "$TARGET:/target" -v "$TARGET/tui-registry:/usr/local/cargo/registry" \ + -e CARGO_TARGET_DIR=/target/tui-build -w /work "$IMAGE" sh -euc " + rustup target add $TARGETS >/dev/null + for t in $TARGETS; do cargo zigbuild --locked --release -p ipx-tui --target \$t; done + mkdir -p /target/tui + # -f replaces a binary someone is running (Text file busy) rather than writing into it. + cp -f /target/tui-build/x86_64-unknown-linux-musl/release/ipx /target/tui/ipx-linux-amd64 + cp -f /target/tui-build/aarch64-apple-darwin/release/ipx /target/tui/ipx-macos-arm64 + " || { + code=$? + [ "$code" -eq 137 ] && echo "killed at the ${MEM} memory cap: tell the user, do not raise it" >&2 + exit "$code" + } +ls -l target/tui