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:
29
PROGRESS.md
29
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="<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
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
10
src/ipc.rs
10
src/ipc.rs
@@ -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,
|
||||||
|
|||||||
20
src/main.rs
20
src/main.rs
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user