Make the UI's Download button download the episode you clicked

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
This commit is contained in:
2026-09-10 01:11:12 +00:00
parent 74ec6e9281
commit 93b4815d84
4 changed files with 100 additions and 5 deletions

View File

@@ -56,6 +56,30 @@ and until now nothing set them.
--- ---
## 2026-09-10 — Fixed: the UI's Download button downloaded the wrong episodes
Reported from the running instance: clicking Download on *Music from a Darkened Room | Session Zero*
showed progress, then left the episode at `pending`.
**Cause, and it was a design error in step 11, not a glitch.** `POST /api/enclosures/{id}/download`
requeued the row to `pending` and asked for an ordinary scan, justified at the time as "needed no
new machinery — the queue is the table". But a scan means *take the lowest-id pending rows, up to
`max_new_per_check`*. Against a 125-item backlog with a cap of 2, that is ids 7 and 8; the requested
row 11 was never a candidate. The progress on screen was two other episodes downloading past it.
The queue can express what is outstanding but not what was *asked for*. `Command::Download {
enclosure }` is now its own command: it fetches that one row immediately, ignoring both queue order
and the per-scan cap, still through the single worker so it cannot overlap a scan. Torrent
enclosures route to `torrent_one` the same way a scan would.
Verified live: enclosure 11 went `pending` -> `done`, 90.3 MB on disk, and nothing else was pulled
in its place. `cargo test` 30/30, with the new command's wire form pinned.
Worth remembering: **the daemon scans immediately on its first tick**, so every restart costs
`max_new_per_check` episodes on the normal path.
---
## 2026-09-10 — Phase 2: web front end (steps 9-13) ## 2026-09-10 — Phase 2: web front end (steps 9-13)
axum in the daemon process, plain HTML/JS in `web/index.html` (embedded with `include_str!`), no axum in the daemon process, plain HTML/JS in `web/index.html` (embedded with `include_str!`), no

View File

@@ -90,6 +90,12 @@ pub enum Command {
#[serde(default)] #[serde(default)]
dry_run: bool, 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, Status,
} }
@@ -234,6 +240,11 @@ mod tests {
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true })); 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()); assert!(serde_json::from_str::<Command>(r#"{"cmd":"nope"}"#).is_err());
} }

View File

@@ -175,6 +175,7 @@ async fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> {
fetch(ctx, feed.as_deref(), force).await fetch(ctx, feed.as_deref(), force).await
} }
Cmd::Reap { dry_run } => reap(ctx, dry_run, true), Cmd::Reap { dry_run } => reap(ctx, dry_run, true),
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
Cmd::Status => { Cmd::Status => {
let (pending, downloaded) = ctx.db.counts()?; let (pending, downloaded) = ctx.db.counts()?;
ctx.out.emit(Event::Status { feeds: ctx.cfg().feeds.len(), pending, downloaded }); ctx.out.emit(Event::Status { feeds: ctx.cfg().feeds.len(), pending, downloaded });
@@ -716,6 +717,63 @@ async fn fetch_one(
Ok((path, got.bytes)) Ok((path, got.bytes))
} }
/// Downloads one specific enclosure immediately, whatever the per-scan cap says and
/// wherever it sits in the queue.
async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
let cfg = ctx.cfg();
let enc = ctx
.db
.enclosure(id)?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
if enc.path.is_some() {
return Ok(()); // Already here.
}
let feed_cfg = cfg
.feeds
.get(&enc.feed_id)
.ok_or_else(|| anyhow::anyhow!("enclosure {id} belongs to unsubscribed feed {:?}", enc.feed_id))?;
let title = ctx.db.feed_summary(&enc.feed_id)?.title;
let folder = download::folder_for(&cfg, &enc.feed_id, feed_cfg, title.as_deref());
let dest_dir = cfg.general.download_dir.join(&folder);
ctx.out.emit(Event::FeedStart { feed: enc.feed_id.clone() });
let is_torrent = download::looks_like_torrent(&enc.url, enc.mime.as_deref());
let result = if is_torrent {
if !cfg.torrent.enabled {
Err(anyhow::anyhow!("torrents are disabled"))
} else {
torrent_one(ctx, &enc.feed_id, &enc.url, &dest_dir).await
}
} else {
fetch_one(ctx, &enc.feed_id, feed_cfg, &enc.url, &dest_dir).await
};
match result {
Ok((path, bytes)) => {
ctx.db.mark_downloaded(&enc.url, &path, bytes)?;
ctx.out.emit(Event::DownloadDone {
feed: enc.feed_id.clone(),
url: enc.url.clone(),
path: path.display().to_string(),
bytes,
});
}
Err(e) => {
let msg = format!("{e:#}");
ctx.db.mark_enclosure(&enc.url, "error", Some(&msg))?;
ctx.out.emit(Event::DownloadError {
feed: enc.feed_id.clone(),
url: enc.url.clone(),
msg,
});
}
}
// Terminal, so a UI waiting on this request stops here.
ctx.out.emit(Event::ScanDone { feeds: 1 });
Ok(())
}
/// Torrent progress is reported the same way an HTTP download's is, throttled to whole /// Torrent progress is reported the same way an HTTP download's is, throttled to whole
/// percents so a UI is not flooded. /// percents so a UI is not flooded.
async fn torrent_one( async fn torrent_one(

View File

@@ -343,8 +343,9 @@ async fn set_flags(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// Puts one enclosure back in the queue and kicks a scan of its feed. The queue is the /// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the
/// table, so this is all it takes -- including for something a filter once skipped. /// lowest-id pending rows up to max_new_per_check, so with a big backlog it would download
/// other episodes and leave the requested one pending.
async fn download_now( async fn download_now(
State(state): State<WebState>, State(state): State<WebState>,
Path(id): Path<i64>, Path(id): Path<i64>,
@@ -358,10 +359,11 @@ async fn download_now(
return Ok(StatusCode::NO_CONTENT); // Already here. return Ok(StatusCode::NO_CONTENT); // Already here.
} }
state.ctx.db.requeue(id)?; state.ctx.db.requeue(id)?;
let _ = state state
.cmds .cmds
.send(Command::Fetch { feed: Some(enc.feed_id), force: true }) .send(Command::Download { enclosure: id })
.await; .await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }