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:
2026-09-10 02:38:14 +00:00
parent 9960befed5
commit e97f2b9c2f
8 changed files with 155 additions and 30 deletions

View File

@@ -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="<id>"]` 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 ## 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 Reported as "my feeds seem to have disappeared", then "settings and dark/light mode don't do

View File

@@ -158,7 +158,8 @@ impl General {
/// Parses a check interval into minutes. /// 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. /// Returns None for anything it cannot read, or for zero.
pub fn parse_interval(s: &str) -> Option<u64> { pub fn parse_interval(s: &str) -> Option<u64> {
let s = s.trim().to_lowercase(); let s = s.trim().to_lowercase();
@@ -178,6 +179,7 @@ pub fn parse_interval(s: &str) -> Option<u64> {
"" | "m" | "min" | "mins" | "minute" | "minutes" => n, "" | "m" | "min" | "mins" | "minute" | "minutes" => n,
"h" | "hr" | "hrs" | "hour" | "hours" => n.checked_mul(60)?, "h" | "hr" | "hrs" | "hour" | "hours" => n.checked_mul(60)?,
"d" | "day" | "days" => n.checked_mul(1440)?, "d" | "day" | "days" => n.checked_mul(1440)?,
"w" | "week" | "weeks" => n.checked_mul(10080)?,
_ => return None, _ => return None,
}; };
(mins > 0).then_some(mins) (mins > 0).then_some(mins)
@@ -339,6 +341,7 @@ mod tests {
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30), ("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240), ("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90), ("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:?}"); assert_eq!(parse_interval(input), Some(want), "{input:?}");
} }

View File

@@ -333,6 +333,7 @@ impl Db {
/// An enclosure waiting to be downloaded. /// An enclosure waiting to be downloaded.
#[derive(Debug)] #[derive(Debug)]
pub struct Pending { pub struct Pending {
pub id: i64,
pub url: String, pub url: String,
pub mime: Option<String>, pub mime: Option<String>,
} }
@@ -343,12 +344,12 @@ impl Db {
pub fn pending(&self, feed_id: &str, limit: usize) -> Result<Vec<Pending>> { pub fn pending(&self, feed_id: &str, limit: usize) -> Result<Vec<Pending>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( 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", WHERE feed_id = ?1 AND state = 'pending' ORDER BY id LIMIT ?2",
)?; )?;
let rows = stmt let rows = stmt
.query_map(rusqlite::params![feed_id, limit as i64], |r| { .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<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows) Ok(rows)

View File

@@ -17,14 +17,17 @@ pub enum Event {
FeedError { feed: String, msg: String }, FeedError { feed: String, msg: String },
Progress { Progress {
feed: String, 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, url: String,
file: String, file: String,
done: u64, done: u64,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
total: Option<u64>, total: Option<u64>,
}, },
DownloadDone { feed: String, url: String, path: String, bytes: u64 }, DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
DownloadError { feed: String, url: String, msg: String }, DownloadError { feed: String, enclosure: i64, url: String, msg: String },
TorrentDeferred { feed: String, url: String }, TorrentDeferred { feed: String, url: String },
Reaped { path: String, bytes: u64 }, Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here. /// Terminal: a client that asked for work stops reading here.
@@ -252,6 +255,7 @@ mod tests {
fn events_serialise_to_the_documented_shape() { fn events_serialise_to_the_documented_shape() {
let ev = Event::Progress { let ev = Event::Progress {
feed: "atp".into(), feed: "atp".into(),
enclosure: 42,
url: "https://x/ep.mp3".into(), url: "https://x/ep.mp3".into(),
file: "ep.mp3".into(), file: "ep.mp3".into(),
done: 10_485_760, done: 10_485_760,
@@ -260,10 +264,12 @@ mod tests {
let json = serde_json::to_string(&ev).unwrap(); let json = serde_json::to_string(&ev).unwrap();
assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}"); assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}");
assert!(json.contains(r#""done":10485760"#)); 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. // total is omitted rather than null when the server sent no length.
let ev = Event::Progress { let ev = Event::Progress {
feed: "a".into(), feed: "a".into(),
enclosure: 1,
url: "u".into(), url: "u".into(),
file: "f".into(), file: "f".into(),
done: 1, done: 1,

View File

@@ -652,11 +652,12 @@ async fn scan_one(
scan.torrents += 1; scan.torrents += 1;
continue; 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)) => { Ok((path, bytes)) => {
ctx.db.mark_downloaded(&item.url, &path, bytes)?; ctx.db.mark_downloaded(&item.url, &path, bytes)?;
ctx.out.emit(Event::DownloadDone { ctx.out.emit(Event::DownloadDone {
feed: id.to_string(), feed: id.to_string(),
enclosure: item.id,
url: item.url.clone(), url: item.url.clone(),
path: path.display().to_string(), path: path.display().to_string(),
bytes, bytes,
@@ -667,6 +668,7 @@ async fn scan_one(
let msg = format!("{e:#}"); let msg = format!("{e:#}");
ctx.out.emit(Event::DownloadError { ctx.out.emit(Event::DownloadError {
feed: id.to_string(), feed: id.to_string(),
enclosure: item.id,
url: item.url.clone(), url: item.url.clone(),
msg: msg.clone(), msg: msg.clone(),
}); });
@@ -676,10 +678,11 @@ async fn scan_one(
} }
continue; 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)) => { Ok((path, bytes)) => {
ctx.out.emit(Event::DownloadDone { ctx.out.emit(Event::DownloadDone {
feed: id.to_string(), feed: id.to_string(),
enclosure: item.id,
url: item.url.clone(), url: item.url.clone(),
path: path.display().to_string(), path: path.display().to_string(),
bytes, bytes,
@@ -690,6 +693,7 @@ async fn scan_one(
let msg = format!("{e:#}"); let msg = format!("{e:#}");
ctx.out.emit(Event::DownloadError { ctx.out.emit(Event::DownloadError {
feed: id.to_string(), feed: id.to_string(),
enclosure: item.id,
url: item.url.clone(), url: item.url.clone(),
msg: msg.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( async fn fetch_one(
ctx: &Ctx, ctx: &Ctx,
feed_id: &str, feed_id: &str,
enclosure: i64,
feed_cfg: &config::Feed, feed_cfg: &config::Feed,
url: &str, url: &str,
dest_dir: &std::path::Path, dest_dir: &std::path::Path,
@@ -742,6 +747,7 @@ async fn fetch_one(
last_pct = pct; last_pct = pct;
ctx.out.emit(Event::Progress { ctx.out.emit(Event::Progress {
feed: feed_id.to_string(), feed: feed_id.to_string(),
enclosure,
url: url.to_string(), url: url.to_string(),
file: name.clone(), file: name.clone(),
done, done,
@@ -760,7 +766,7 @@ async fn fetch_one(
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled"))?; ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled"))?;
anyhow::bail!("body is a torrent and torrents are 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?; 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 { if !cfg.torrent.enabled {
Err(anyhow::anyhow!("torrents are disabled")) Err(anyhow::anyhow!("torrents are disabled"))
} else { } 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 { } 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 { match result {
@@ -805,6 +811,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
ctx.db.mark_downloaded(&enc.url, &path, bytes)?; ctx.db.mark_downloaded(&enc.url, &path, bytes)?;
ctx.out.emit(Event::DownloadDone { ctx.out.emit(Event::DownloadDone {
feed: enc.feed_id.clone(), feed: enc.feed_id.clone(),
enclosure: enc.id,
url: enc.url.clone(), url: enc.url.clone(),
path: path.display().to_string(), path: path.display().to_string(),
bytes, bytes,
@@ -815,6 +822,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
ctx.db.mark_enclosure(&enc.url, "error", Some(&msg))?; ctx.db.mark_enclosure(&enc.url, "error", Some(&msg))?;
ctx.out.emit(Event::DownloadError { ctx.out.emit(Event::DownloadError {
feed: enc.feed_id.clone(), feed: enc.feed_id.clone(),
enclosure: enc.id,
url: enc.url.clone(), url: enc.url.clone(),
msg, msg,
}); });
@@ -830,6 +838,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
async fn torrent_one( async fn torrent_one(
ctx: &Ctx, ctx: &Ctx,
feed_id: &str, feed_id: &str,
enclosure: i64,
url: &str, url: &str,
dest_dir: &std::path::Path, dest_dir: &std::path::Path,
) -> Result<(PathBuf, u64)> { ) -> Result<(PathBuf, u64)> {
@@ -845,6 +854,7 @@ async fn torrent_one(
last_pct = pct; last_pct = pct;
ctx.out.emit(Event::Progress { ctx.out.emit(Event::Progress {
feed: feed_id.to_string(), feed: feed_id.to_string(),
enclosure,
url: url.to_string(), url: url.to_string(),
file: name.clone(), file: name.clone(),
done, done,

View File

@@ -154,6 +154,8 @@ struct FeedRow {
auto_download: bool, auto_download: bool,
max_new_per_check: Option<usize>, max_new_per_check: Option<usize>,
schedule: Option<String>, 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>. /// Effective schedule in minutes, after the global default and the feed's <ttl>.
every_mins: u64, every_mins: u64,
last_checked: Option<i64>, 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, auto_download: feed.auto_download,
max_new_per_check: feed.max_new_per_check, max_new_per_check: feed.max_new_per_check,
schedule: feed.schedule.clone(), 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, every_mins: crate::due_after(&cfg, feed, st.ttl_mins) / 60,
last_checked: s.last_checked, last_checked: s.last_checked,
next_check: s next_check: s

View File

@@ -39,7 +39,13 @@ const ctx = {
window: { isSecureContext: false, addEventListener(){} }, window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} }, localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined }, 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 = () => {}; }, EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {}, MediaMetadata: function () {},
Blob: function () {}, Blob: function () {},
@@ -59,6 +65,33 @@ try {
process.exit(1); 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) { if (missing.length) {
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', ')); console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
process.exit(1); process.exit(1);

View File

@@ -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 img{max-width:100%;height:auto;border-radius:6px}
.notes p:first-child{margin-top:0}.notes p:last-child{margin-bottom:0} .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} .dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s}
.empty{color:var(--faint);text-align:center;padding:50px 0} .empty{color:var(--faint);text-align:center;padding:50px 0}
#more{display:block;width:100%;margin-top:10px} #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{display:flex;gap:6px;align-items:stretch}
.inline input{flex:1;min-width:0} .inline input{flex:1;min-width:0}
.inline .btn{white-space:nowrap;flex:none} .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)} .check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px} .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} #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; 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
? `<option value=""${sel===''?' selected':''}>${firstLabel}</option>` : '';
return head + UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
}
function everyText(m){ function everyText(m){
if(!m) return '\u2014'; if(!m) return '\u2014';
if(m % 1440 === 0) return (m/1440)+(m===1440?' day':' days'); const {n,u} = splitEvery(m);
if(m % 60 === 0) return (m/60)+(m===60?' hour':' hours'); const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
return m+' min'; return n + ' ' + name + (u!=='m' && n!==1 ? 's' : '');
} }
function due(ts){ function due(ts){
const d = ts - Date.now()/1000; const d = ts - Date.now()/1000;
@@ -688,12 +706,15 @@ function due(ts){
async function prefsModal(){ async function prefsModal(){
const g = await api('/api/settings'); const g = await api('/api/settings');
globalEvery = g.every_mins; globalEvery = g.every_mins;
const gs = splitEvery(g.every_mins);
openModal(`<h3>Settings</h3> openModal(`<h3>Settings</h3>
<div class="field"><label>Check feeds</label> <div class="field"><label>Check feeds every</label>
<input type="text" id="gsched" value="${esc(g.schedule)}"> <div class="inline">
<span class="hint">How often every feed is re-checked unless it overrides this. <input type="number" id="gnum" min="1" max="999" value="${gs.n}">
Try <b>every 30m</b>, <b>every 4h</b>, <b>1d</b>. A feed's own suggested interval <select id="gunit">${unitOptions(gs.u)}</select>
(its <b>ttl</b>) is honoured when it asks to be polled less often than this.</span></div> </div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label> <div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}"> <input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played episodes are deleted first. Starred <span class="hint">Over this, the oldest played episodes are deleted first. Starred
@@ -707,7 +728,7 @@ async function prefsModal(){
$('#gsave').onclick=async()=>{ $('#gsave').onclick=async()=>{
try{ try{
await api('/api/settings',{method:'PATCH',body:JSON.stringify({ 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_total_gb:Number($('#gquota').value)||0,
max_age_days:Number($('#gage').value)||0})}); max_age_days:Number($('#gage').value)||0})});
closeModal(); toast('Settings saved'); closeModal(); toast('Settings saved');
@@ -717,6 +738,7 @@ async function prefsModal(){
} }
function settingsModal(f){ function settingsModal(f){
const fs = splitEvery(f.schedule_mins || globalEvery);
openModal(`<h3>${esc(f.title||f.id)}</h3> openModal(`<h3>${esc(f.title||f.id)}</h3>
<div class="field"><label>Download folder</label> <div class="field"><label>Download folder</label>
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}"></div> <input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}"></div>
@@ -724,9 +746,12 @@ function settingsModal(f){
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}"> <input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div> <span class="hint">Comma separated. Empty takes everything.</span></div>
<div class="field"><label>Check schedule</label> <div class="field"><label>Check schedule</label>
<input type="text" id="ssched" value="${esc(f.schedule||'')}" placeholder="default — every ${everyText(globalEvery)}"> <div class="inline">
<span class="hint">e.g. <b>every 30m</b>, <b>every 6h</b>, <b>2d</b>. Empty follows the global <input type="number" id="snum" min="1" max="999" value="${fs.n}" ${f.schedule_mins?'':'disabled'}>
schedule. Setting one here overrides the feed's own suggested interval.</span></div> <select id="sunit">${unitOptions(f.schedule_mins?fs.u:'', `Use the default — every ${everyText(globalEvery)}`)}</select>
</div>
<span class="hint">Overrides the global schedule, and the feed's own suggested
interval, for this feed only.</span></div>
<div class="field"><label>Max new downloads per scan</label> <div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}"> <input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank means no limit. The rest wait for the next scan.</span></div> <span class="hint">Blank means no limit. The rest wait for the next scan.</span></div>
@@ -742,13 +767,16 @@ function settingsModal(f){
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button> <div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="ssave">Save</button></div>`); <button class="btn primary" id="ssave">Save</button></div>`);
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); $('#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()=>{ $('#ssave').onclick=async()=>{
const max=$('#smax').value; const max=$('#smax').value;
try{ try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({
url:$('#surl').value.trim(), url:$('#surl').value.trim(),
folder:$('#sfolder').value.trim()||null, 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), keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
max_new_per_check:max===''?null:Number(max), max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked})}); auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked})});
@@ -830,13 +858,22 @@ function connect(){
let ev; try{ ev=JSON.parse(m.data) }catch{ return } let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){ if(ev.ev==='progress'){
const pct=ev.total?ev.done/ev.total*100:0; const pct=ev.total?ev.done/ev.total*100:0;
$$('.dlbar').forEach(b=>{ /* progress applies to whatever is downloading now */ // Only the row actually downloading. Without the enclosure id this used to paint
if(b.dataset.bar) b.firstElementChild.style.width=pct+'%'; // 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)}%`); $('#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_done'){
else if(ev.ev==='download_error'){ toast('Download failed: '+ev.msg,true); loadEntries(); } 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_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==='feed_error'){ toast(ev.feed+': '+ev.msg,true); loadFeeds(true); }
else if(ev.ev==='scan_done'){ loadFeeds(true); if(S.feed) loadEntries(); } else if(ev.ev==='scan_done'){ loadFeeds(true); if(S.feed) loadEntries(); }