//! Unix-socket control and event stream. Replaces printMSG's `;;1;;1;;100.00;;42.31`. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, mpsc}; /// One JSON object per line, `ev` naming the variant. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "ev", rename_all = "snake_case")] pub enum Event { FeedStart { feed: String }, FeedSkip { feed: String, reason: String }, FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize }, FeedError { feed: String, msg: String }, Progress { feed: String, /// Which enclosure this is about. Without it a UI cannot tell one download's /// progress from another's and ends up animating every pending row. enclosure: i64, url: String, file: String, done: u64, #[serde(skip_serializing_if = "Option::is_none")] total: Option, }, DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 }, DownloadError { feed: String, enclosure: i64, url: String, msg: String }, TorrentDeferred { feed: String, url: String }, Reaped { path: String, bytes: u64 }, /// Terminal: a client that asked for work stops reading here. ScanDone { feeds: usize }, ReapDone { files: usize, bytes: u64 }, Status { feeds: usize, pending: i64, downloaded: i64 }, Error { msg: String }, } impl Event { pub fn is_terminal(&self) -> bool { matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. }) } /// The human rendering, for a terminal rather than a UI. pub fn human(&self) -> Option { Some(match self { Event::FeedSkip { feed, reason } => format!("{feed}: {reason}"), Event::FeedDone { feed, new, downloaded, failed, torrents } => format!( "{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred" ), Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"), Event::Progress { file, done, total, .. } => match total { Some(t) if *t > 0 => format!( " {file}: {:.1}% ({:.1}/{:.1} MB)", *done as f64 / *t as f64 * 100.0, *done as f64 / 1_048_576.0, *t as f64 / 1_048_576.0 ), _ => format!(" {file}: {:.1} MB", *done as f64 / 1_048_576.0), }, Event::DownloadDone { path, .. } => format!(" saved {path}"), Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"), Event::Reaped { path, bytes } => { format!("reap {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0) } Event::ReapDone { files, bytes } => format!( "reaped {files} file(s), {:.1} MB", *bytes as f64 / 1_048_576.0 ), Event::Status { feeds, pending, downloaded } => { format!("{feeds} feeds, {pending} pending, {downloaded} downloaded") } Event::Error { msg } => format!("error: {msg}"), // Noise in a terminal; a UI still gets them on the socket. Event::FeedStart { feed } => format!("{feed}: checking"), Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"), }) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] pub enum Command { Fetch { #[serde(default)] feed: Option, #[serde(default)] force: bool, }, Reap { #[serde(default)] dry_run: bool, }, /// Fetch one specific enclosure now, ignoring max_new_per_check and the queue order. /// A scan cannot express "this one, now": it takes the lowest-id pending rows up to /// the per-scan cap, so an explicit request has to bypass both. Download { enclosure: i64, }, Status, } /// Where events go: the socket, the terminal, or both. #[derive(Clone)] pub struct Emitter { tx: Option>, print: bool, } impl Emitter { pub fn terminal() -> Self { Self { tx: None, print: true } } pub fn socket(tx: broadcast::Sender, print: bool) -> Self { Self { tx: Some(tx), print } } pub fn emit(&self, e: Event) { // Also log it. Scans and downloads travel as events, not tracing calls, so // without this the log view shows only startup and HTTP lines and none of the // work the daemon is actually doing. Progress goes to debug: it fires on every // whole percent and would otherwise crowd everything else out of the buffer. // Level by how much it matters. With 80-odd feeds in an OPML subscription, one // line per feed per tick for "not due yet" would push everything worth reading // out of the buffer within a few minutes. let routine = match &e { Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true, Event::FeedDone { new, downloaded, failed, torrents, .. } => { *new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0 } _ => false, }; let bad = matches!( &e, Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. } ); if let Some(line) = e.human() { let line = line.trim(); if bad { tracing::warn!(target: "ipx::scan", "{line}"); } else if routine { tracing::debug!(target: "ipx::scan", "{line}"); } else { tracing::info!(target: "ipx::scan", "{line}"); } } if let Some(tx) = &self.tx { // An error here only means nobody is listening yet. let _ = tx.send(e.clone()); } if self.print && let Some(line) = e.human() { println!("{line}"); } } } /// True when something is already listening -- i.e. a daemon owns this socket. pub async fn daemon_is_live(path: &Path) -> bool { UnixStream::connect(path).await.is_ok() } /// Accepts connections, feeding commands to `cmds` and events from `events` back out. pub async fn serve( path: PathBuf, events: broadcast::Sender, cmds: mpsc::Sender, ) -> Result<()> { // A socket file left by a crashed daemon would block the bind; a live one was already // rejected by the caller's daemon_is_live() check. if path.exists() { std::fs::remove_file(&path) .with_context(|| format!("removing stale socket {}", path.display()))?; } if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } let listener = UnixListener::bind(&path) .with_context(|| format!("binding {}", path.display()))?; tracing::info!(socket = %path.display(), "listening"); loop { let (stream, _) = listener.accept().await?; let rx = events.subscribe(); let cmds = cmds.clone(); tokio::spawn(async move { if let Err(e) = handle(stream, rx, cmds).await { tracing::debug!(error = %e, "client gone"); } }); } } async fn handle( stream: UnixStream, mut rx: broadcast::Receiver, cmds: mpsc::Sender, ) -> Result<()> { let (read, mut write) = stream.into_split(); // Events out. let writer = tokio::spawn(async move { while let Ok(ev) = rx.recv().await { let mut line = serde_json::to_string(&ev).unwrap_or_default(); line.push('\n'); if write.write_all(line.as_bytes()).await.is_err() { break; } } }); // Commands in. let mut lines = BufReader::new(read).lines(); while let Some(line) = lines.next_line().await? { let line = line.trim(); if line.is_empty() { continue; } match serde_json::from_str::(line) { Ok(cmd) => { if cmds.send(cmd).await.is_err() { break; // Worker is gone; so are we. } } Err(e) => tracing::warn!(error = %e, line, "bad command"), } } writer.abort(); Ok(()) } /// Sends one command to a running daemon and prints the events it produces. pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> { let stream = UnixStream::connect(path).await?; let (read, mut write) = stream.into_split(); let mut line = serde_json::to_string(cmd)?; line.push('\n'); write.write_all(line.as_bytes()).await?; // ponytail: the event stream is a broadcast, so a busy daemon's other work shows up // here too. Fine for a CLI; a UI that cares would want per-request ids. let mut lines = BufReader::new(read).lines(); while let Some(line) = lines.next_line().await? { let Ok(ev) = serde_json::from_str::(&line) else { continue; }; if let Some(text) = ev.human() { println!("{text}"); } if ev.is_terminal() { break; } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn commands_parse_from_the_wire_form() { let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap(); assert!(matches!(got, Command::Fetch { feed: None, force: false })); let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap(); assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp")); let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap(); assert!(matches!(got, Command::Reap { dry_run: true })); // "Download this one now" is its own command precisely because a scan cannot // express it: a scan takes the lowest-id pending rows up to max_new_per_check. let got: Command = serde_json::from_str(r#"{"cmd":"download","enclosure":11}"#).unwrap(); assert!(matches!(got, Command::Download { enclosure: 11 })); assert!(serde_json::from_str::(r#"{"cmd":"nope"}"#).is_err()); } #[test] fn events_serialise_to_the_documented_shape() { let ev = Event::Progress { feed: "atp".into(), enclosure: 42, url: "https://x/ep.mp3".into(), file: "ep.mp3".into(), done: 10_485_760, total: Some(52_428_800), }; let json = serde_json::to_string(&ev).unwrap(); assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}"); assert!(json.contains(r#""done":10485760"#)); assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row"); // total is omitted rather than null when the server sent no length. let ev = Event::Progress { feed: "a".into(), enclosure: 1, url: "u".into(), file: "f".into(), done: 1, total: None, }; assert!(!serde_json::to_string(&ev).unwrap().contains("total")); } #[test] fn only_completion_events_end_a_client_session() { assert!(Event::ScanDone { feeds: 1 }.is_terminal()); assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal()); assert!(!Event::FeedDone { feed: "a".into(), new: 0, downloaded: 0, failed: 0, torrents: 0 } .is_terminal()); } }