Show that DeadAir is still working on an episode (#176)

A new download in a show with DeadAir on plays at full length until its
copy is made, and nothing said so. Each file now carries
deadair_pending while DeadAir is on for its show and the copy is still
to come (not for one heard through, which it passes over), and the
episode's line and details say "Cutting dead air…". The maker sends a
deadair_done event when it finishes a file, and the page takes the list
again, so the line becomes how much shorter the episode is without a
reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 19:05:18 +00:00
parent 527a90ed2d
commit d6c0e3e7a6
9 changed files with 67 additions and 7 deletions

View File

@@ -202,6 +202,7 @@ impl From<enclosures::Model> for EncRow {
EncRow {
id: e.id,
deadair: None,
deadair_pending: false,
feed_id: e.feed_id,
guid: e.guid,
url: e.url,
@@ -1184,6 +1185,10 @@ pub struct EncRow {
/// (`deadair::for_reader`). None from the database.
#[serde(skip_serializing_if = "Option::is_none")]
pub deadair: Option<i64>,
/// DeadAir is on for this person's show and the copy is still being made: the page says to
/// wait rather than leave them guessing (`deadair::for_reader`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub deadair_pending: bool,
pub feed_id: String,
pub guid: String,
pub url: String,
@@ -2388,6 +2393,22 @@ impl Db {
.collect()
}
/// Which of these enclosures DeadAir has dealt with since each was last downloaded, copy or
/// none: the rest on disk are still waiting for it.
pub async fn deadair_done(&self, enclosure_ids: &[i64]) -> Result<std::collections::HashSet<i64>> {
if enclosure_ids.is_empty() {
return Ok(Default::default());
}
let mut a = Args::default();
let ids: Vec<String> = enclosure_ids.iter().map(|&i| a.p(i)).collect();
let sql = format!(
"SELECT d.enclosure_id FROM deadair d JOIN enclosures e ON e.id = d.enclosure_id
WHERE d.enclosure_id IN ({}) AND d.made_at >= coalesce(e.downloaded_at, 0)",
ids.join(", ")
);
self.rows(&sql, a.0).await?.iter().map(|r| Ok(r.try_get("", "enclosure_id")?)).collect()
}
/// Counts an episode towards the time DeadAir has saved someone, once however often it is
/// played.
pub async fn count_deadair(&self, user_id: i64, feed_id: &str, guid: &str, saved_ms: i64) -> Result<()> {

View File

@@ -189,9 +189,13 @@ pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow])
.collect();
let copies: HashMap<i64, crate::db::Copy> =
ctx.db.copies(&ids).await?.into_iter().map(|c| (c.enclosure_id, c)).collect();
let done = ctx.db.deadair_done(&ids).await?;
for r in rows.iter_mut() {
// Heard through already: the maker passes it over, so nothing is coming.
let finished = r.duration.is_some_and(|d| d > 0 && r.position * 10 >= d * 9);
let mut converted = false;
for e in &mut r.enclosures {
e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !done.contains(&e.id) && !finished;
let Some(c) = copies.get(&e.id) else { continue };
e.deadair = Some(c.saved_ms / 1000);
// One position an item, however many files: the first with a copy sets the clock.
@@ -308,8 +312,9 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
};
let saved_ms = saved(&cuts);
ctx.db.store_deadair(t.id, path.as_deref(), &serde_json::to_string(&cuts)?, saved_ms).await?;
ctx.out.emit(crate::ipc::Event::DeadairDone { feed: t.feed_id.clone(), enclosure: t.id, saved_s: saved_ms / 1000 });
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
tracing::info!(
ev = "deadair",
feed = t.feed_id,
path = %src.display(),
cuts = cuts.len(),

View File

@@ -32,6 +32,8 @@ pub enum Event {
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 },
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize },
@@ -79,6 +81,7 @@ impl Event {
// Noise in a terminal; a UI still gets them on the socket.
Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir copy made, {saved_s} s shorter"),
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
})
}

View File

@@ -2515,9 +2515,15 @@ mod tests {
};
let off = page().await;
assert_eq!((off.position, off.enclosures[0].deadair), (300, None), "off until turned on for the feed");
assert!(!off.enclosures[0].deadair_pending);
db.set_deadair(u, "f", Some(true)).await.unwrap();
db.sql("DELETE FROM deadair").await.unwrap();
let waiting = page().await;
assert!(waiting.enclosures[0].deadair_pending, "on, and the copy not made yet");
db.store_deadair(7, Some("/x/a.deadair.mp3"), "[[60000,120000],[200000,260000]]", 120_000).await.unwrap();
let on = page().await;
assert!(!on.enclosures[0].deadair_pending);
assert_eq!((on.position, on.duration, on.enclosures[0].deadair), (180, Some(480), Some(120)));
// Saved from the copy at 3:20 of its 8:00: 5:20 of the original's 10:00, short of 75%.