diff --git a/CHANGELOG.md b/CHANGELOG.md index 927e1ac..e18ef1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/db.rs b/src/db.rs index 9b008b9..7efada3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -202,6 +202,7 @@ impl From 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, + /// 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> { + if enclosure_ids.is_empty() { + return Ok(Default::default()); + } + let mut a = Args::default(); + let ids: Vec = 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<()> { diff --git a/src/deadair.rs b/src/deadair.rs index 426a13d..bae23ed 100644 --- a/src/deadair.rs +++ b/src/deadair.rs @@ -189,9 +189,13 @@ pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) .collect(); let copies: HashMap = 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) -> 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(), diff --git a/src/ipc.rs b/src/ipc.rs index 6ebd6eb..d61eca2 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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)"), }) } diff --git a/src/main.rs b/src/main.rs index b403027..4c8427c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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%. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 0e2fd88..209640a 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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); +}); diff --git a/web/src/events.ts b/web/src/events.ts index 06d8c49..b859482 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -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'); diff --git a/web/src/items.ts b/web/src/items.ts index 8cf90b7..8feee8d 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -79,6 +79,7 @@ function epEl(e){ left&&`${left}`, others>0&&`+${others} more file${others===1?'':'s'}`, enc&&enc.deadair>0&&`${shorter(enc.deadair)}`, + enc&&enc.deadair_pending&&DEAD_PENDING, enc&&enc.last_error&&enc.state==='error'&&`${esc(enc.last_error)}`, ].filter(Boolean).join('')} @@ -300,7 +301,8 @@ function detailHtml(e){ themselves would read as a mistake. */ e.title&&e.title.trim() ? `

${esc(e.title)}

` : ''}
${/* 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=>`${s}`).join('')} diff --git a/web/src/util.ts b/web/src/util.ts index 735b7e1..0954a5d 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -60,6 +60,9 @@ const EQ=''; // 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='Cutting dead air…'; /// 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".