POST /api/enclosures/{id}/download requeued the row and asked for a
normal scan, but a scan takes the lowest-id pending rows up to
max_new_per_check. With a large backlog and a small cap the requested
row was never a candidate, so other episodes downloaded while it stayed
pending.
A queue expresses what is outstanding, not what was asked for. Download
is now its own command that fetches one specific enclosure immediately,
ignoring queue order and the per-scan cap, still via the single worker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
289 lines
9.9 KiB
Rust
289 lines
9.9 KiB
Rust
//! 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,
|
|
url: String,
|
|
file: String,
|
|
done: u64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
total: Option<u64>,
|
|
},
|
|
DownloadDone { feed: String, url: String, path: String, bytes: u64 },
|
|
DownloadError { feed: String, 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<String> {
|
|
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 { .. } | Event::TorrentDeferred { .. } | Event::ScanDone { .. } => {
|
|
return None;
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "cmd", rename_all = "snake_case")]
|
|
pub enum Command {
|
|
Fetch {
|
|
#[serde(default)]
|
|
feed: Option<String>,
|
|
#[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<broadcast::Sender<Event>>,
|
|
print: bool,
|
|
}
|
|
|
|
impl Emitter {
|
|
pub fn terminal() -> Self {
|
|
Self { tx: None, print: true }
|
|
}
|
|
|
|
pub fn socket(tx: broadcast::Sender<Event>, print: bool) -> Self {
|
|
Self { tx: Some(tx), print }
|
|
}
|
|
|
|
pub fn emit(&self, e: Event) {
|
|
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<Event>,
|
|
cmds: mpsc::Sender<Command>,
|
|
) -> 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<Event>,
|
|
cmds: mpsc::Sender<Command>,
|
|
) -> 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::<Command>(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::<Event>(&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::<Command>(r#"{"cmd":"nope"}"#).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn events_serialise_to_the_documented_shape() {
|
|
let ev = Event::Progress {
|
|
feed: "atp".into(),
|
|
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"#));
|
|
|
|
// total is omitted rather than null when the server sent no length.
|
|
let ev = Event::Progress {
|
|
feed: "a".into(),
|
|
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());
|
|
}
|
|
}
|