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

@@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is, and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172).
- An episode DeadAir is still working on says "Cutting dead air…", and changes to how much shorter it is when it is done, without a reload (#176).
### Fixed
- A downloaded file's play, save, share and delete buttons stay on one row in the Files pane, instead of delete wrapping underneath (#175).
- DeadAir shortens an episode you have opened but not played yet, instead of passing it over as if you had finished it (#173).
- DeadAir shortens episodes with more than about a hundred long silences, which is most of an hour's conversation, instead of failing on them, and tries a file it failed on again after the next restart (#174).
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.

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%.

View File

@@ -1772,12 +1772,12 @@ test('DeadAir is off until turned on for a show, then plays its shortened copy a
await expect(sw).not.toBeChecked();
await sw.locator('xpath=..').click();
await page.locator('#ssave').click();
// Made at once, not at the next pass: about 1.8 s off each of four 2 s silences.
await expect.poll(async () => (await entry()).enclosures[0].deadair ?? 0, { timeout: 20_000 }).toBeGreaterThan(5);
await page.reload();
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
// Made at once, not at the next pass, and the row says so when it is done without a reload:
// "Cutting dead air…" until then, then about 1.8 s off each of four 2 s silences.
const row = page.locator('.ep', { hasText: 'Pauses Between' });
await expect(row).toContainText('s shorter');
await expect(row).toContainText('s shorter', { timeout: 20_000 });
expect((await entry()).enclosures[0].deadair).toBeGreaterThan(5);
await expect(row).not.toContainText('Cutting dead air');
// Played from the copy, with the switch beside the speed pressed.
await row.locator('[data-a=play]').click();
@@ -1792,3 +1792,14 @@ test('DeadAir is off until turned on for a show, then plays its shortened copy a
await page.locator('#pclose').click();
await page.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
});
test('a downloaded file\'s buttons sit on one row in the files pane', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await expect(page.locator('.ep').first()).toBeVisible();
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path))));
const box = page.locator('#files .encbox').first();
await expect(box.locator('.btn')).toHaveCount(4);
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
});

View File

@@ -35,6 +35,13 @@ function connect(){
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries();
}
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
else if(ev.ev==='deadair_done'){
if(!mine(ev.feed)) return;
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
refreshEntries();
if(S.sel) loadEntries().then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');

View File

@@ -79,6 +79,7 @@ function epEl(e){
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
enc&&enc.deadair_pending&&DEAD_PENDING,
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
].filter(Boolean).join('<span class="dot"></span>')}</div>
</div>
@@ -300,7 +301,8 @@ function detailHtml(e){
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
<div class="dmeta">
${/* Joined, so a missing date or number leaves no stray dot behind. */
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>

View File

@@ -60,6 +60,9 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
/// becomes how much shorter it is once the copy is ready (the dead_air event).
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
/// What DeadAir took off an episode, as the list says it.
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s".