From e97f2b9c2f3536c5a53035f8bfde37f14c1a9505 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 02:38:14 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 29 ++++++++++++++++++ src/config.rs | 5 ++- src/db.rs | 5 +-- src/ipc.rs | 10 ++++-- src/main.rs | 20 +++++++++--- src/web.rs | 6 ++++ tests/page-smoke.js | 35 ++++++++++++++++++++- web/index.html | 75 +++++++++++++++++++++++++++++++++------------ 8 files changed, 155 insertions(+), 30 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index e9f9be4..a2e0087 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,35 @@ and until now nothing set them. --- +## 2026-09-10 — Schedule pickers, and progress painting every row + +**Pickers.** "Check feeds every" is a number input plus a unit dropdown (minutes/hours/days/weeks) +in both global and per-feed Settings, replacing free text. `parse_interval` gained weeks first — it +only knew m/h/d, so the backend would have rejected the new option. The per-feed dropdown's first +entry is "Use the default — every N", which disables the number and sends `null`; that rides on the +null-clearing path fixed earlier today, and would not have worked before it. `splitEvery` picks the +largest unit that divides evenly, so 120 reads "2 hours" rather than "120 min". The API now returns +`schedule_mins` so the page does not re-implement the parser. + +**Bug: one download painted every pending row's progress bar.** Reported after adding the TWiT feed. +The `progress` event carried feed/url/file but no enclosure id, so the handler had nothing to target +and set the width on all of them — the code even carried a comment admitting it applied "to whatever +is downloading now". Adding a feed therefore looked like it was downloading the entire back +catalogue. + +`Event::Progress`, `DownloadDone` and `DownloadError` now carry `enclosure`, threaded through both +the HTTP and torrent paths (`db::Pending` gained its row id to make that possible), and the handler +targets `.dlbar[data-bar=""]` alone. A test asserts the id is on the wire. Idle bars are +transparent and only get a track while live, so a row that is not downloading shows nothing. + +`tests/page-smoke.js` also drives each modal with live-shaped data now — load-time smoke never +reaches code that only runs when a dialog opens, which is exactly where these changes landed. + +Verified live: weeks round-trips (`every 2w` -> 20160 min), both pickers render, clearing an +override returns to the global default. `cargo test` 37/37. + +--- + ## 2026-09-10 — The whole UI was dead, and server-side tests could not see it Reported as "my feeds seem to have disappeared", then "settings and dark/light mode don't do diff --git a/src/config.rs b/src/config.rs index 00e3c9f..123e7c1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { let s = s.trim().to_lowercase(); @@ -178,6 +179,7 @@ pub fn parse_interval(s: &str) -> Option { "" | "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:?}"); } diff --git a/src/db.rs b/src/db.rs index 356ccfe..2fede90 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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, } @@ -343,12 +344,12 @@ impl Db { pub fn pending(&self, feed_id: &str, limit: usize) -> Result> { 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::>>()?; Ok(rows) diff --git a/src/ipc.rs b/src/ipc.rs index d66922a..9718884 100644 --- a/src/ipc.rs +++ b/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, }, - 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, diff --git a/src/main.rs b/src/main.rs index 835ac89..32cdee3 100644 --- a/src/main.rs +++ b/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, diff --git a/src/web.rs b/src/web.rs index 8846b49..f214ac1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -154,6 +154,8 @@ struct FeedRow { auto_download: bool, max_new_per_check: Option, schedule: Option, + /// The feed's own override in minutes, so the UI need not re-parse the string. + schedule_mins: Option, /// Effective schedule in minutes, after the global default and the feed's . every_mins: u64, last_checked: Option, @@ -181,6 +183,10 @@ async fn feeds(State(state): State) -> Result>, 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 diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 91454b5..da234b6 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -39,7 +39,13 @@ const ctx = { window: { isSecureContext: false, addEventListener(){} }, localStorage: { getItem: () => null, setItem(){}, removeItem(){} }, navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined }, - fetch: () => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]), text: () => Promise.resolve('') }), + fetch: (url) => Promise.resolve({ + ok: true, status: 200, text: () => Promise.resolve(''), + json: () => Promise.resolve( + String(url).includes('/api/settings') + ? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 } + : []), + }), EventSource: function () { this.close = () => {}; }, MediaMetadata: function () {}, Blob: function () {}, @@ -59,6 +65,33 @@ try { process.exit(1); } +// The modals are built on demand, so a load-time check never reaches them. Drive the +// ones that construct markup from live data, which is where a bad field reference hides. +const feed = { + id: 'f', url: 'https://x/rss', title: 'A Feed', image: null, folder: null, + keywords: ['a'], allow_explicit: false, auto_download: true, max_new_per_check: 3, + schedule: 'every 6h', schedule_mins: 360, every_mins: 360, + last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null, +}; +const drive = [ + ['settingsModal', () => ctx.settingsModal(feed)], + ['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })], + ['downloadLatestModal', () => ctx.downloadLatestModal(feed)], + ['removeFeed', () => ctx.removeFeed(feed)], + ['prefsModal', () => ctx.prefsModal()], +]; +for (const [name, fn] of drive) { + try { + const r = fn(); + if (r && typeof r.catch === 'function') r.catch(e => { + console.error(`FAIL: ${name} rejected: ${e.message}`); process.exit(1); + }); + } catch (e) { + console.error(`FAIL: ${name} threw: ${e.message}`); + process.exit(1); + } +} + if (missing.length) { console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', ')); process.exit(1); diff --git a/web/index.html b/web/index.html index 8b9fcee..01142c3 100644 --- a/web/index.html +++ b/web/index.html @@ -168,7 +168,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} } .notes img{max-width:100%;height:auto;border-radius:6px} .notes p:first-child{margin-top:0}.notes p:last-child{margin-bottom:0} -.dlbar{height:3px;background:var(--raise);border-radius:2px;overflow:hidden;margin-top:7px} +.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px} +.dlbar.live{background:var(--raise)} .dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s} .empty{color:var(--faint);text-align:center;padding:50px 0} #more{display:block;width:100%;margin-top:10px} @@ -224,6 +225,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu .inline{display:flex;gap:6px;align-items:stretch} .inline input{flex:1;min-width:0} .inline .btn{white-space:nowrap;flex:none} +.inline select{flex:none;width:auto} +.inline input[type=number]{flex:none;width:90px} .check input{width:16px;height:16px;accent-color:var(--accent)} .cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px} #toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60} @@ -671,11 +674,26 @@ $('#addFeed').onclick=()=>{ }; let globalEvery = 60; +const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; +const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; + +/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". +function splitEvery(m){ + if(!m) return {n:1, u:'h'}; + for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; + return {n:m, u:'m'}; +} +function unitOptions(sel, firstLabel){ + const head = firstLabel + ? `` : ''; + return head + UNITS.map(([v,l]) => + ``).join(''); +} function everyText(m){ if(!m) return '\u2014'; - if(m % 1440 === 0) return (m/1440)+(m===1440?' day':' days'); - if(m % 60 === 0) return (m/60)+(m===60?' hour':' hours'); - return m+' min'; + const {n,u} = splitEvery(m); + const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; + return n + ' ' + name + (u!=='m' && n!==1 ? 's' : ''); } function due(ts){ const d = ts - Date.now()/1000; @@ -688,12 +706,15 @@ function due(ts){ async function prefsModal(){ const g = await api('/api/settings'); globalEvery = g.every_mins; + const gs = splitEvery(g.every_mins); openModal(`

Settings

-
- - How often every feed is re-checked unless it overrides this. - Try every 30m, every 4h, 1d. A feed's own suggested interval - (its ttl) is honoured when it asks to be polled less often than this.
+
+
+ + +
+ Applies to every feed that does not set its own. A feed's suggested + interval (its ttl) is still honoured when it asks to be polled less often.
Over this, the oldest played episodes are deleted first. Starred @@ -707,7 +728,7 @@ async function prefsModal(){ $('#gsave').onclick=async()=>{ try{ await api('/api/settings',{method:'PATCH',body:JSON.stringify({ - schedule:$('#gsched').value.trim(), + schedule:`every ${Math.max(1,Number($('#gnum').value)||1)}${$('#gunit').value}`, max_total_gb:Number($('#gquota').value)||0, max_age_days:Number($('#gage').value)||0})}); closeModal(); toast('Settings saved'); @@ -717,6 +738,7 @@ async function prefsModal(){ } function settingsModal(f){ + const fs = splitEvery(f.schedule_mins || globalEvery); openModal(`

${esc(f.title||f.id)}

@@ -724,9 +746,12 @@ function settingsModal(f){ Comma separated. Empty takes everything.
- - e.g. every 30m, every 6h, 2d. Empty follows the global - schedule. Setting one here overrides the feed's own suggested interval.
+
+ + +
+ Overrides the global schedule, and the feed's own suggested + interval, for this feed only.
Blank means no limit. The rest wait for the next scan.
@@ -742,13 +767,16 @@ function settingsModal(f){
`); $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); + // An empty unit means "follow the global default", so the number has nothing to say. + $('#sunit').onchange=()=>{ $('#snum').disabled = !$('#sunit').value; }; $('#ssave').onclick=async()=>{ const max=$('#smax').value; try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({ url:$('#surl').value.trim(), folder:$('#sfolder').value.trim()||null, - schedule:$('#ssched').value.trim()||null, + schedule:$('#sunit').value + ? `every ${Math.max(1,Number($('#snum').value)||1)}${$('#sunit').value}` : null, keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean), max_new_per_check:max===''?null:Number(max), auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked})}); @@ -830,13 +858,22 @@ function connect(){ let ev; try{ ev=JSON.parse(m.data) }catch{ return } if(ev.ev==='progress'){ const pct=ev.total?ev.done/ev.total*100:0; - $$('.dlbar').forEach(b=>{ /* progress applies to whatever is downloading now */ - if(b.dataset.bar) b.firstElementChild.style.width=pct+'%'; - }); + // Only the row actually downloading. Without the enclosure id this used to paint + // every pending bar at once, so adding a feed looked like it was fetching the lot. + const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"] i`); + if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); } $('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`); } - else if(ev.ev==='download_done'){ toast('Downloaded '+ev.path.split('/').pop()); loadEntries(); loadFeeds(true); } - else if(ev.ev==='download_error'){ toast('Download failed: '+ev.msg,true); loadEntries(); } + else if(ev.ev==='download_done'){ + const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); + if(bar) bar.classList.remove('live'); + toast('Downloaded '+ev.path.split('/').pop()); loadEntries(); loadFeeds(true); + } + else if(ev.ev==='download_error'){ + const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); + if(bar) bar.classList.remove('live'); + toast('Download failed: '+ev.msg,true); loadEntries(); + } else if(ev.ev==='feed_done'){ if(ev.new) toast(`${ev.feed}: ${ev.new} new`); loadFeeds(true); if(ev.feed===S.feed) loadEntries(); } else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); loadFeeds(true); } else if(ev.ev==='scan_done'){ loadFeeds(true); if(S.feed) loadEntries(); }