Schedule pickers, and target progress at one row
"Check feeds every" becomes a number plus a unit dropdown in both global and per-feed settings; parse_interval gained weeks to back it. The per-feed dropdown can select the global default, clearing the override. Fixes progress painting every pending row: the event carried no enclosure id, so the handler had nothing to target and set the width on all of them. Adding a feed looked like it was downloading everything. Progress, DownloadDone and DownloadError now carry the enclosure id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
@@ -158,7 +158,8 @@ impl General {
|
||||
|
||||
/// Parses a check interval into minutes.
|
||||
///
|
||||
/// Accepts "every 30m", "30m", "4h", "1d", "every 4 hours", or a bare number of minutes.
|
||||
/// Accepts "every 30m", "30m", "4h", "1d", "2w", "every 4 hours", or a bare number of
|
||||
/// minutes.
|
||||
/// Returns None for anything it cannot read, or for zero.
|
||||
pub fn parse_interval(s: &str) -> Option<u64> {
|
||||
let s = s.trim().to_lowercase();
|
||||
@@ -178,6 +179,7 @@ pub fn parse_interval(s: &str) -> Option<u64> {
|
||||
"" | "m" | "min" | "mins" | "minute" | "minutes" => n,
|
||||
"h" | "hr" | "hrs" | "hour" | "hours" => n.checked_mul(60)?,
|
||||
"d" | "day" | "days" => n.checked_mul(1440)?,
|
||||
"w" | "week" | "weeks" => n.checked_mul(10080)?,
|
||||
_ => return None,
|
||||
};
|
||||
(mins > 0).then_some(mins)
|
||||
@@ -339,6 +341,7 @@ mod tests {
|
||||
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
|
||||
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
|
||||
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
|
||||
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
|
||||
] {
|
||||
assert_eq!(parse_interval(input), Some(want), "{input:?}");
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ impl Db {
|
||||
/// An enclosure waiting to be downloaded.
|
||||
#[derive(Debug)]
|
||||
pub struct Pending {
|
||||
pub id: i64,
|
||||
pub url: String,
|
||||
pub mime: Option<String>,
|
||||
}
|
||||
@@ -343,12 +344,12 @@ impl Db {
|
||||
pub fn pending(&self, feed_id: &str, limit: usize) -> Result<Vec<Pending>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT url, mime FROM enclosures
|
||||
"SELECT id, url, mime FROM enclosures
|
||||
WHERE feed_id = ?1 AND state = 'pending' ORDER BY id LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![feed_id, limit as i64], |r| {
|
||||
Ok(Pending { url: r.get(0)?, mime: r.get(1)? })
|
||||
Ok(Pending { id: r.get(0)?, url: r.get(1)?, mime: r.get(2)? })
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
|
||||
10
src/ipc.rs
10
src/ipc.rs
@@ -17,14 +17,17 @@ pub enum Event {
|
||||
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<u64>,
|
||||
},
|
||||
DownloadDone { feed: String, url: String, path: String, bytes: u64 },
|
||||
DownloadError { feed: String, url: String, msg: String },
|
||||
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.
|
||||
@@ -252,6 +255,7 @@ mod tests {
|
||||
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,
|
||||
@@ -260,10 +264,12 @@ mod tests {
|
||||
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,
|
||||
|
||||
20
src/main.rs
20
src/main.rs
@@ -652,11 +652,12 @@ async fn scan_one(
|
||||
scan.torrents += 1;
|
||||
continue;
|
||||
}
|
||||
match torrent_one(ctx, id, &item.url, &dest_dir).await {
|
||||
match torrent_one(ctx, id, item.id, &item.url, &dest_dir).await {
|
||||
Ok((path, bytes)) => {
|
||||
ctx.db.mark_downloaded(&item.url, &path, bytes)?;
|
||||
ctx.out.emit(Event::DownloadDone {
|
||||
feed: id.to_string(),
|
||||
enclosure: item.id,
|
||||
url: item.url.clone(),
|
||||
path: path.display().to_string(),
|
||||
bytes,
|
||||
@@ -667,6 +668,7 @@ async fn scan_one(
|
||||
let msg = format!("{e:#}");
|
||||
ctx.out.emit(Event::DownloadError {
|
||||
feed: id.to_string(),
|
||||
enclosure: item.id,
|
||||
url: item.url.clone(),
|
||||
msg: msg.clone(),
|
||||
});
|
||||
@@ -676,10 +678,11 @@ async fn scan_one(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match fetch_one(ctx, id, feed_cfg, &item.url, &dest_dir).await {
|
||||
match fetch_one(ctx, id, item.id, feed_cfg, &item.url, &dest_dir).await {
|
||||
Ok((path, bytes)) => {
|
||||
ctx.out.emit(Event::DownloadDone {
|
||||
feed: id.to_string(),
|
||||
enclosure: item.id,
|
||||
url: item.url.clone(),
|
||||
path: path.display().to_string(),
|
||||
bytes,
|
||||
@@ -690,6 +693,7 @@ async fn scan_one(
|
||||
let msg = format!("{e:#}");
|
||||
ctx.out.emit(Event::DownloadError {
|
||||
feed: id.to_string(),
|
||||
enclosure: item.id,
|
||||
url: item.url.clone(),
|
||||
msg: msg.clone(),
|
||||
});
|
||||
@@ -727,6 +731,7 @@ fn reject(feed_cfg: &config::Feed, entry: &feed::Entry, url: &str) -> Option<&'s
|
||||
async fn fetch_one(
|
||||
ctx: &Ctx,
|
||||
feed_id: &str,
|
||||
enclosure: i64,
|
||||
feed_cfg: &config::Feed,
|
||||
url: &str,
|
||||
dest_dir: &std::path::Path,
|
||||
@@ -742,6 +747,7 @@ async fn fetch_one(
|
||||
last_pct = pct;
|
||||
ctx.out.emit(Event::Progress {
|
||||
feed: feed_id.to_string(),
|
||||
enclosure,
|
||||
url: url.to_string(),
|
||||
file: name.clone(),
|
||||
done,
|
||||
@@ -760,7 +766,7 @@ async fn fetch_one(
|
||||
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled"))?;
|
||||
anyhow::bail!("body is a torrent and torrents are disabled");
|
||||
}
|
||||
return torrent_one(ctx, feed_id, url, dest_dir).await;
|
||||
return torrent_one(ctx, feed_id, enclosure, url, dest_dir).await;
|
||||
}
|
||||
|
||||
let path = download::place(&got, dest_dir).await?;
|
||||
@@ -794,10 +800,10 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
||||
if !cfg.torrent.enabled {
|
||||
Err(anyhow::anyhow!("torrents are disabled"))
|
||||
} else {
|
||||
torrent_one(ctx, &enc.feed_id, &enc.url, &dest_dir).await
|
||||
torrent_one(ctx, &enc.feed_id, enc.id, &enc.url, &dest_dir).await
|
||||
}
|
||||
} else {
|
||||
fetch_one(ctx, &enc.feed_id, feed_cfg, &enc.url, &dest_dir).await
|
||||
fetch_one(ctx, &enc.feed_id, enc.id, feed_cfg, &enc.url, &dest_dir).await
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -805,6 +811,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
||||
ctx.db.mark_downloaded(&enc.url, &path, bytes)?;
|
||||
ctx.out.emit(Event::DownloadDone {
|
||||
feed: enc.feed_id.clone(),
|
||||
enclosure: enc.id,
|
||||
url: enc.url.clone(),
|
||||
path: path.display().to_string(),
|
||||
bytes,
|
||||
@@ -815,6 +822,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
||||
ctx.db.mark_enclosure(&enc.url, "error", Some(&msg))?;
|
||||
ctx.out.emit(Event::DownloadError {
|
||||
feed: enc.feed_id.clone(),
|
||||
enclosure: enc.id,
|
||||
url: enc.url.clone(),
|
||||
msg,
|
||||
});
|
||||
@@ -830,6 +838,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
||||
async fn torrent_one(
|
||||
ctx: &Ctx,
|
||||
feed_id: &str,
|
||||
enclosure: i64,
|
||||
url: &str,
|
||||
dest_dir: &std::path::Path,
|
||||
) -> Result<(PathBuf, u64)> {
|
||||
@@ -845,6 +854,7 @@ async fn torrent_one(
|
||||
last_pct = pct;
|
||||
ctx.out.emit(Event::Progress {
|
||||
feed: feed_id.to_string(),
|
||||
enclosure,
|
||||
url: url.to_string(),
|
||||
file: name.clone(),
|
||||
done,
|
||||
|
||||
@@ -154,6 +154,8 @@ struct FeedRow {
|
||||
auto_download: bool,
|
||||
max_new_per_check: Option<usize>,
|
||||
schedule: Option<String>,
|
||||
/// The feed's own override in minutes, so the UI need not re-parse the string.
|
||||
schedule_mins: Option<u64>,
|
||||
/// Effective schedule in minutes, after the global default and the feed's <ttl>.
|
||||
every_mins: u64,
|
||||
last_checked: Option<i64>,
|
||||
@@ -181,6 +183,10 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
|
||||
auto_download: feed.auto_download,
|
||||
max_new_per_check: feed.max_new_per_check,
|
||||
schedule: feed.schedule.clone(),
|
||||
schedule_mins: feed
|
||||
.schedule
|
||||
.as_deref()
|
||||
.and_then(crate::config::parse_interval),
|
||||
every_mins: crate::due_after(&cfg, feed, st.ttl_mins) / 60,
|
||||
last_checked: s.last_checked,
|
||||
next_check: s
|
||||
|
||||
Reference in New Issue
Block a user