diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27e9e4a..542bbc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182).
+
### Changed
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
-- DeadAir removes a shortened copy nobody has played for a week, to save space, and makes it again when someone next plays the episode (#179).
+- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space (#181).
## [0.11.0] - 2026-10-09
diff --git a/package-lock.json b/package-lock.json
index ec54f06..b4420c3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,8 @@
"name": "ipx-web",
"dependencies": {
"@swc/core": "^1.16.2",
- "@swc/html": "^1.16.2"
+ "@swc/html": "^1.16.2",
+ "hls.js": "1.6.19"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
@@ -871,6 +872,12 @@
"node": ">=16.20.0"
}
},
+ "node_modules/hls.js": {
+ "version": "1.6.19",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.19.tgz",
+ "integrity": "sha512-A//aZeaAgM5rgUCbTURD4gkfyw5Uag2/4Jky7CfDOukL/DN+P5DjbgK2BYteyP3/KnCkPfkGVkbLxQG2T0AbUQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
diff --git a/package.json b/package.json
index b661d6b..a900298 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
},
"dependencies": {
"@swc/core": "^1.16.2",
- "@swc/html": "^1.16.2"
+ "@swc/html": "^1.16.2",
+ "hls.js": "1.6.19"
}
}
diff --git a/src/db.rs b/src/db.rs
index edcf9e3..3c5976d 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -60,7 +60,7 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(blocklists::Entity),
schema.create_table_from_entity(hidden::Entity),
schema.create_table_from_entity(api_tokens::Entity),
- schema.create_table_from_entity(deadair::Entity),
+ schema.create_table_from_entity(deadair_plans::Entity),
schema.create_table_from_entity(deadair_prefs::Entity),
schema.create_table_from_entity(deadair_saved::Entity),
] {
@@ -91,10 +91,26 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
- if orm.execute_unprepared("SELECT played_at FROM deadair LIMIT 0").await.is_err() {
- let sql = "ALTER TABLE deadair ADD COLUMN played_at BIGINT";
+ if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
+ let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
+ // DeadAir made a shortened copy of each file until it played the original through a
+ // playlist (#181). The copies go with their table, and the files are looked at again, which
+ // takes seconds each. Found only while the table is there, so nothing is written after.
+ let copies = Statement::from_string(orm.get_database_backend(), "SELECT path FROM deadair WHERE path IS NOT NULL");
+ if let Ok(rows) = orm.query_all_raw(copies).await {
+ for r in rows {
+ // Only DeadAir's own files, `name.deadair.mp3`: anything else would be someone's original.
+ if let Ok(path) = r.try_get::("", "path")
+ && path.contains(".deadair.")
+ {
+ let _ = std::fs::remove_file(&path);
+ }
+ }
+ // IF EXISTS: the healthcheck's `ipx status` may open the database at the same moment.
+ orm.execute_unprepared("DROP TABLE IF EXISTS deadair").await.context("dropping DeadAir's copies")?;
+ }
Ok(())
}
@@ -2257,28 +2273,21 @@ pub struct DeadAirTodo {
pub path: String,
}
-/// An enclosure's DeadAir copy: where it is, what it left out, and how much that came to.
-#[derive(Debug, Clone)]
-pub struct Copy {
- pub enclosure_id: i64,
- pub path: String,
- pub cuts: Vec,
- pub saved_ms: i64,
-}
-
impl Db {
- /// DeadAir settings as (person, feed, wanted), everyone's or one person's. An empty feed is
- /// the switch beside the speed.
- pub async fn deadair_prefs(&self, user_id: Option) -> Result> {
+ /// DeadAir settings as (person, feed, wanted, pause in ms), everyone's or one person's. An
+ /// empty feed is the switch beside the speed.
+ pub async fn deadair_prefs(&self, user_id: Option) -> Result)>> {
let mut a = Args::default();
let only = match user_id {
Some(u) => format!("WHERE user_id = {}", a.p(u)),
None => String::new(),
};
- self.rows(&format!("SELECT user_id, feed_id, wanted FROM deadair_prefs {only}"), a.0)
+ self.rows(&format!("SELECT user_id, feed_id, wanted, pause_ms FROM deadair_prefs {only}"), a.0)
.await?
.iter()
- .map(|r| Ok((r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "wanted")?)))
+ .map(|r| {
+ Ok((r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "wanted")?, r.try_get("", "pause_ms")?))
+ })
.collect()
}
@@ -2301,11 +2310,22 @@ impl Db {
Ok(())
}
- /// Files on disk with no copy made since they were downloaded, oldest first.
+ /// Sets the pause DeadAir leaves in a feed for someone (#182). Only beside a setting of the
+ /// switch: inserting one here would turn a feed's group's DeadAir off for it.
+ pub async fn set_deadair_pause(&self, user_id: i64, feed_id: &str, pause_ms: i64) -> Result<()> {
+ self.exec(
+ "UPDATE deadair_prefs SET pause_ms = $1 WHERE user_id = $2 AND feed_id = $3",
+ vec![pause_ms.into(), user_id.into(), feed_id.into()],
+ )
+ .await?;
+ Ok(())
+ }
+
+ /// Files on disk DeadAir has not looked at since they were downloaded, oldest first.
pub async fn deadair_todo(&self) -> Result> {
self.rows(
"SELECT e.id, e.feed_id, e.guid, e.path FROM enclosures e
- LEFT JOIN deadair d ON d.enclosure_id = e.id
+ LEFT JOIN deadair_plans d ON d.enclosure_id = e.id
WHERE e.path IS NOT NULL AND e.state = 'done'
AND (d.enclosure_id IS NULL OR d.made_at < coalesce(e.downloaded_at, 0))
ORDER BY e.id",
@@ -2356,104 +2376,42 @@ impl Db {
.collect()
}
- /// Records the copy made of an enclosure, or with no path that it has none.
- pub async fn store_deadair(&self, enclosure_id: i64, path: Option<&str>, cuts: &str, saved_ms: i64) -> Result<()> {
+ /// Records what DeadAir found in an enclosure: its plans as JSON, `{}` for none.
+ pub async fn store_plans(&self, enclosure_id: i64, plans: &str) -> Result<()> {
self.exec(
- "INSERT INTO deadair (enclosure_id, path, cuts, saved_ms, made_at) VALUES ($1, $2, $3, $4, $5)
- ON CONFLICT (enclosure_id) DO UPDATE SET path = excluded.path, cuts = excluded.cuts,
- saved_ms = excluded.saved_ms, made_at = excluded.made_at",
- vec![enclosure_id.into(), path.map(str::to_owned).into(), cuts.into(), saved_ms.into(), now().into()],
+ "INSERT INTO deadair_plans (enclosure_id, plans, made_at) VALUES ($1, $2, $3)
+ ON CONFLICT (enclosure_id) DO UPDATE SET plans = excluded.plans, made_at = excluded.made_at",
+ vec![enclosure_id.into(), plans.into(), now().into()],
)
.await?;
Ok(())
}
- /// Notes that someone is playing a copy, so it is kept. At most once an hour: the player asks
- /// for more of the file at every seek.
- pub async fn deadair_played(&self, enclosure_id: i64) -> Result<()> {
- let now = now();
- self.exec(
- "UPDATE deadair SET played_at = $1 WHERE enclosure_id = $2 AND coalesce(played_at, 0) < $3",
- vec![now.into(), enclosure_id.into(), (now - 3600).into()],
- )
- .await?;
- Ok(())
- }
-
- /// Copies nobody has played, or made, since `before`, as (enclosure, path).
- pub async fn stale_copies(&self, before: i64) -> Result> {
- self.rows(
- "SELECT enclosure_id, path FROM deadair WHERE path IS NOT NULL AND coalesce(played_at, made_at) < $1",
- vec![before.into()],
- )
- .await?
- .iter()
- .map(|r| Ok((r.try_get("", "enclosure_id")?, r.try_get("", "path")?)))
- .collect()
- }
-
- /// Records a copy's file as removed, keeping its cuts: they mark it as one to make again
- /// (`revive_copy`).
- pub async fn drop_copy_path(&self, enclosure_id: i64) -> Result<()> {
- self.exec("UPDATE deadair SET path = NULL WHERE enclosure_id = $1", vec![enclosure_id.into()]).await?;
- Ok(())
- }
-
- /// Puts an episode whose copy was removed back on DeadAir's list, and says whether it was one.
- /// A file with no copy because it cannot have one has no cuts, and stays off the list.
- pub async fn revive_copy(&self, enclosure_id: i64) -> Result {
- let n = self
- .exec(
- "DELETE FROM deadair WHERE enclosure_id = $1 AND path IS NULL AND cuts <> '[]'",
- vec![enclosure_id.into()],
- )
- .await?;
- Ok(n > 0)
- }
-
- /// The copies there are of these enclosures, made since each was last downloaded.
- pub async fn copies(&self, enclosure_ids: &[i64]) -> Result> {
- if enclosure_ids.is_empty() {
- return Ok(vec![]);
- }
- let mut a = Args::default();
- let ids: Vec = enclosure_ids.iter().map(|&i| a.p(i)).collect();
- let sql = format!(
- "SELECT d.enclosure_id, d.path, d.cuts, d.saved_ms FROM deadair d
- JOIN enclosures e ON e.id = d.enclosure_id
- WHERE d.enclosure_id IN ({}) AND d.path IS NOT NULL AND e.path IS NOT NULL
- AND d.made_at >= coalesce(e.downloaded_at, 0)",
- ids.join(", ")
- );
- self.rows(&sql, a.0)
- .await?
- .iter()
- .map(|r| {
- let cuts: String = r.try_get("", "cuts")?;
- Ok(Copy {
- enclosure_id: r.try_get("", "enclosure_id")?,
- path: r.try_get("", "path")?,
- cuts: serde_json::from_str(&cuts).context("reading DeadAir's cuts")?,
- saved_ms: r.try_get("", "saved_ms")?,
- })
- })
- .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> {
+ /// DeadAir's plans for these enclosures, by pause, for each one it has looked at since it
+ /// was last downloaded and is still on disk. Empty for a file it does not apply to; missing
+ /// for one it has yet to look at.
+ pub async fn plans(
+ &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)",
+ "SELECT d.enclosure_id, d.plans FROM deadair_plans d JOIN enclosures e ON e.id = d.enclosure_id
+ WHERE d.enclosure_id IN ({}) AND e.path IS NOT NULL 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()
+ self.rows(&sql, a.0)
+ .await?
+ .iter()
+ .map(|r| {
+ let plans: String = r.try_get("", "plans")?;
+ Ok((r.try_get("", "enclosure_id")?, serde_json::from_str(&plans).context("reading DeadAir's plans")?))
+ })
+ .collect()
}
/// Counts an episode towards the time DeadAir has saved someone, once however often it is
@@ -2507,13 +2465,40 @@ mod tests {
use super::*;
#[tokio::test]
- async fn deadair_copies_are_made_once_per_download_and_counted_once_per_episode() {
+ async fn deadair_drops_its_old_copies_once() {
+ let db = Db::memory().await.unwrap();
+ let dir = std::env::temp_dir().join(format!("ipx-deadair-old-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let (orig, copy) = (dir.join("a.mp3"), dir.join("a.deadair.mp3"));
+ std::fs::write(&orig, "original").unwrap();
+ std::fs::write(©, "copy").unwrap();
+ db.orm.execute_unprepared("CREATE TABLE deadair (enclosure_id BIGINT PRIMARY KEY, path TEXT)").await.unwrap();
+ for (id, path) in [(1, ©), (2, &orig)] {
+ db.orm
+ .execute_unprepared(&format!("INSERT INTO deadair VALUES ({id}, '{}')", path.display()))
+ .await
+ .unwrap();
+ }
+ create_missing(&db.orm).await.unwrap();
+ assert!(!copy.exists(), "the copy goes");
+ assert!(orig.exists(), "a path that is not DeadAir's own is never removed");
+ assert!(db.orm.execute_unprepared("SELECT 1 FROM deadair").await.is_err(), "and the table");
+ create_missing(&db.orm).await.unwrap();
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[tokio::test]
+ async fn deadair_looks_at_a_file_once_per_download_and_counts_an_episode_once() {
let db = Db::memory().await.unwrap();
let u = db.create_user("dee", None, false).await.unwrap();
db.set_deadair(u, "f", Some(true)).await.unwrap();
db.set_deadair(u, "g", Some(false)).await.unwrap();
db.set_deadair(u, "g", None).await.unwrap();
- assert_eq!(db.deadair_prefs(Some(u)).await.unwrap(), vec![(u, "f".to_string(), true)]);
+ assert_eq!(db.deadair_prefs(Some(u)).await.unwrap(), vec![(u, "f".to_string(), true, None)]);
+ db.set_deadair_pause(u, "f", 500).await.unwrap();
+ // Only beside a setting of the switch: a row of its own would turn DeadAir off for "g".
+ db.set_deadair_pause(u, "g", 400).await.unwrap();
+ assert_eq!(db.deadair_prefs(Some(u)).await.unwrap(), vec![(u, "f".to_string(), true, Some(500))]);
for sql in [
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
VALUES (1, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
@@ -2525,14 +2510,14 @@ mod tests {
let todo = db.deadair_todo().await.unwrap();
assert_eq!(todo.iter().map(|t| t.id).collect::>(), [1], "only a file on disk");
- db.store_deadair(1, Some("/x/a.deadair.mp3"), "[[1000,3000]]", 2000).await.unwrap();
+ db.store_plans(1, r#"{"300": {"cuts": [[1000,3000]], "pieces": [[0,10,1.0]]}}"#).await.unwrap();
assert!(db.deadair_todo().await.unwrap().is_empty());
- let c = db.copies(&[1, 2]).await.unwrap();
- assert_eq!((c.len(), c[0].cuts.as_slice(), c[0].saved_ms), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000));
- // Downloaded again since: the copy is stale until made again.
+ let p = db.plans(&[1, 2]).await.unwrap();
+ assert_eq!((p.len(), p[&1][&300].cuts.as_slice(), p[&1][&300].saved_ms()), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000));
+ // Downloaded again since: looked at again before it plays shortened.
db.orm.execute_unprepared(&format!("UPDATE enclosures SET downloaded_at = {} WHERE id = 1", now() + 60)).await.unwrap();
assert_eq!(db.deadair_todo().await.unwrap().len(), 1);
- assert!(db.copies(&[1]).await.unwrap().is_empty());
+ assert!(db.plans(&[1]).await.unwrap().is_empty());
assert!(!db.subscribed_any(&[u], "f").await.unwrap());
db.subscribe(u, "f").await.unwrap();
diff --git a/src/deadair.rs b/src/deadair.rs
index 439a0aa..fd26889 100644
--- a/src/deadair.rs
+++ b/src/deadair.rs
@@ -1,18 +1,21 @@
-//! DeadAir (issue #171): podcasts play from a copy with their long silences shortened, so they
-//! finish sooner without sounding sped up.
+//! DeadAir (issue #171): podcasts play with their long silences shortened, so they finish
+//! sooner without sounding sped up.
//!
-//! The copy is the original's own frames with the silent ones left out, copied by ffmpeg with
-//! nothing re-encoded: about 10 s an hour of audio on one core, finding the silences included.
-//! It is made in the background, one file at a time, for files in feeds someone has DeadAir on
-//! for, and kept beside the original (`copy_path`) until `download::remove` takes both.
+//! Nothing is copied (#181). DeadAir finds a file's silences once, in the background, and keeps a
+//! `Plan` for each pause length a person can choose (#182): the frames kept, as byte ranges of
+//! the original. The player gets an HLS playlist of those ranges (`playlist`), which Safari plays
+//! itself and other browsers through hls.js, with no gap at the cuts. Seeking past each silence
+//! instead left about 1.3 s of nothing at every one (#178); a copy made with ffmpeg cost as much
+//! disk again as the original and could only have one pause length.
//!
-//! Positions stay in the original's time. What the copy left out is stored as `Cut`s of the
+//! Positions stay in the original's time. What a plan leaves out is stored as `Cut`s of the
//! original's time, and the server converts both ways (`to_copy`, `to_original`): the iOS and
//! Android apps save positions themselves, from their own player, so converting in the page
//! would miss them.
-use std::collections::HashMap;
-use std::path::{Path, PathBuf};
+use std::collections::{BTreeMap, HashMap};
+use std::fmt::Write as _;
+use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
@@ -24,35 +27,37 @@ use crate::Ctx;
/// issue used. A podcast's room tone sits well below it and speech well above.
const THRESHOLD: &str = "-38dB";
const MIN_SILENCE: f64 = 0.35;
-/// Kept at each end of a silence, so the frames on both sides of a cut are silent. Decoding
+/// The pauses a person can have DeadAir leave where it cuts a silence, in ms (#182). Half of it
+/// is kept at each end of the silence, so the frames on both sides of a cut are silent. Decoding
/// blends a frame with the one before it, and an MP3 frame can begin in the bytes of the frame
/// before (the bit reservoir): the first frame after a cut decodes against the wrong neighbour,
/// which nobody hears between two silent frames and everyone hears at the start of a word.
-const KEEP: f64 = 0.1;
-/// ffmpeg takes the frames to drop as one argument, and Linux refuses an argument over 128 KiB:
-/// 3500 of them, numbered into the millions, come to about 110 KiB.
-// shortcut: a file with more silences than this keeps only its longest; about 7 hours of
-// conversation at the rate measured. Pass the list some other way if a feed ever gets there.
-const MAX_CUTS: usize = 3500;
+pub const PAUSES: [i64; 4] = [200, 300, 400, 500];
+/// 0.2 s, the first DeadAir's, made speech sound rushed.
+pub const DEFAULT_PAUSE: i64 = 300;
+/// The longest piece of a playlist, in seconds: about what HLS players expect, so a long stretch
+/// of talk is not one request for megabytes.
+const MAX_PIECE: f64 = 10.0;
/// How long the maker waits when there is nothing to do: a download or a feed turned on is
/// picked up this long after, at most.
const IDLE: std::time::Duration = std::time::Duration::from_secs(120);
-/// How long a copy is kept after it was last played, or made if nobody has played it (#179). It
-/// takes nearly as much space as the original, and playing the original makes it again.
-const KEEP_COPY: i64 = 7 * 24 * 3600;
-/// A span of the original, in milliseconds, that the copy leaves out.
+/// A span of the original, in milliseconds, that DeadAir leaves out.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cut(pub i64, pub i64);
-/// Where the copy of `original` goes: beside it, `name.deadair.mp3` for `name.mp3`.
-pub fn copy_path(original: &Path) -> PathBuf {
- let stem = original.file_stem().map(|s| s.to_string_lossy()).unwrap_or_default();
- let name = match original.extension() {
- Some(ext) => format!("{stem}.deadair.{}", ext.to_string_lossy()),
- None => format!("{stem}.deadair"),
- };
- original.with_file_name(name)
+/// What one pause length leaves of a file: what it cuts, and what it keeps as pieces of the
+/// original, (byte offset, bytes, seconds).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Plan {
+ pub cuts: Vec,
+ pub pieces: Vec<(u64, u64, f64)>,
+}
+
+impl Plan {
+ pub fn saved_ms(&self) -> i64 {
+ saved(&self.cuts)
+ }
}
/// The silences silencedetect reported, as (start, end) seconds. One still open at the end of
@@ -79,29 +84,69 @@ fn after(line: &str, key: &str) -> Option {
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same
/// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
-/// silence, `KEEP` in from each end, go.
-pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64) -> (Vec<(u64, u64)>, Vec) {
- let mut spans: Vec<(u64, u64)> = silences
+/// silence, `keep` seconds in from each end, go.
+pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64, keep: f64) -> (Vec<(u64, u64)>, Vec) {
+ let spans: Vec<(u64, u64)> = silences
.iter()
.filter(|(s, e)| e - s >= MIN_SILENCE)
.filter_map(|&(s, e)| {
- let a = ((s + KEEP - first) / frame).ceil().max(0.0) as u64;
- let b = ((e - KEEP - first) / frame).floor() as i64 - 1;
+ let a = ((s + keep - first) / frame).ceil().max(0.0) as u64;
+ let b = ((e - keep - first) / frame).floor() as i64 - 1;
(b >= a as i64).then_some((a, b as u64))
})
.collect();
- if spans.len() > MAX_CUTS {
- spans.sort_by_key(|&(a, b)| std::cmp::Reverse(b - a));
- spans.truncate(MAX_CUTS);
- spans.sort();
- }
let ms = |n: u64| ((first + n as f64 * frame) * 1000.0).round() as i64;
let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
(spans, cuts)
}
-/// A time in the original, in ms, as the same moment in the copy. A time inside a cut is where
-/// the cut was made.
+/// The frames kept, as pieces of the file: runs of frames lying end to end in it, none past
+/// `MAX_PIECE`. `frames` is each frame's (byte offset, bytes); `drop` is from `plan`.
+pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u64, u64, f64)> {
+ let most = ((MAX_PIECE / frame).floor() as usize).max(1);
+ let mut out = Vec::new();
+ let push = |out: &mut Vec<(u64, u64, f64)>, (first, n): (usize, usize)| {
+ let (start, (pos, size)) = (frames[first].0, frames[first + n - 1]);
+ out.push((start, pos + size - start, n as f64 * frame));
+ };
+ let mut drops = drop.iter().peekable();
+ let mut run: Option<(usize, usize)> = None;
+ for (n, &(pos, _)) in frames.iter().enumerate() {
+ while drops.peek().is_some_and(|&&(_, b)| (b as usize) < n) {
+ drops.next();
+ }
+ let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
+ // Something between two frames (an ID3 tag partway through) ends a piece too.
+ let joins = !dropped
+ && run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
+ if !joins && let Some(r) = run.take() {
+ push(&mut out, r);
+ }
+ if !dropped {
+ run = Some(run.map_or((n, 1), |(f, c)| (f, c + 1)));
+ }
+ }
+ if let Some(r) = run {
+ push(&mut out, r);
+ }
+ out
+}
+
+/// The HLS playlist for a plan: each piece a byte range of `uri`, the original.
+pub fn playlist(plan: &Plan, uri: &str) -> String {
+ let longest = plan.pieces.iter().map(|p| p.2).fold(0.0, f64::max);
+ let mut s = format!(
+ "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:{}\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n",
+ longest.ceil() as u64
+ );
+ for &(off, len, secs) in &plan.pieces {
+ let _ = write!(s, "#EXTINF:{secs:.6},\n#EXT-X-BYTERANGE:{len}@{off}\n{uri}\n");
+ }
+ s + "#EXT-X-ENDLIST\n"
+}
+
+/// A time in the original, in ms, as the same moment in the shortened version. A time inside a
+/// cut is where the cut was made.
pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms;
for &Cut(s, e) in cuts {
@@ -117,7 +162,7 @@ pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
out
}
-/// A time in the copy, in ms, as the same moment in the original.
+/// A time in the shortened version, in ms, as the same moment in the original.
pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms;
for &Cut(s, e) in cuts {
@@ -134,24 +179,29 @@ pub fn saved(cuts: &[Cut]) -> i64 {
cuts.iter().map(|Cut(s, e)| e - s).sum()
}
-/// Whether someone with these settings plays a feed from its copies. Off for every feed until
-/// they turn it on for that one, or for the group it came in (an OPML or a Patreon creator),
-/// which a feed of the group can still say otherwise to.
+/// Whether someone with these settings plays a feed shortened. Off for every feed until they
+/// turn it on for that one, or for the group it came in (an OPML or a Patreon creator), which a
+/// feed of the group can still say otherwise to.
pub fn wants(prefs: &HashMap, feed: &str, group: Option<&str>) -> bool {
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false)
}
/// The settings of each person with DeadAir on for something, and each managed feed's group,
-/// which is what `wants` needs.
+/// which is what `wants` and `pause` need.
pub struct Wanting {
pub people: HashMap>,
+ pub pauses: HashMap<(i64, String), i64>,
pub groups: HashMap,
}
impl Wanting {
pub async fn load(ctx: &Ctx, only: Option) -> Result {
let mut people: HashMap> = HashMap::new();
- for (user, feed, wanted) in ctx.db.deadair_prefs(only).await? {
+ let mut pauses = HashMap::new();
+ for (user, feed, wanted, pause) in ctx.db.deadair_prefs(only).await? {
+ if let Some(p) = pause {
+ pauses.insert((user, feed.clone()), p);
+ }
people.entry(user).or_default().insert(feed, wanted);
}
people.retain(|_, p| p.values().any(|&w| w));
@@ -160,7 +210,7 @@ impl Wanting {
} else {
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect()
};
- Ok(Self { people, groups })
+ Ok(Self { people, pauses, groups })
}
pub fn wants(&self, user: i64, feed: &str) -> bool {
@@ -168,18 +218,44 @@ impl Wanting {
.get(&user)
.is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
}
+
+ /// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
+ pub fn pause(&self, user: i64, feed: &str) -> i64 {
+ let group = self.groups.get(feed);
+ self.pauses
+ .get(&(user, feed.to_owned()))
+ .or_else(|| group.and_then(|g| self.pauses.get(&(user, g.clone()))))
+ .copied()
+ .unwrap_or(DEFAULT_PAUSE)
+ }
}
-/// What each person is playing from a copy, by (person, feed, guid): set when `/media` serves
-/// them a copy and cleared when it serves the original, so a saved position is converted from
-/// the clock it was measured in, whether the page saved it or a native app did.
-// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
-// for more of the copy is then taken as the original's, which resumes a little early, never late.
-pub type Playing = Arc>>>;
+/// The plan someone plays a file with: their pause's, for a file in a feed they have DeadAir on
+/// for, once its silences are found. None if it cuts nothing.
+fn chosen(wanting: &Wanting, user: i64, feed: &str, plans: &BTreeMap) -> Option {
+ if !wanting.wants(user, feed) {
+ return None;
+ }
+ plans.get(&wanting.pause(user, feed)).filter(|p| !p.cuts.is_empty()).cloned()
+}
-/// Puts a page of entries in the clock of what this person will play: for each file with a
-/// copy they play, `deadair` is the seconds it saves, and the entry's position and length are
-/// the copy's. The page plays `/media/{id}/deadair` for a file that has it.
+/// What each person is playing shortened, by (person, feed, guid): set when they are handed the
+/// playlist and cleared when `/media` serves them the original, so a saved position is converted
+/// from the clock it was measured in, whether the page saved it or a native app did.
+// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
+// for the playlist is then taken as the original's, which resumes a little early, never late.
+pub type Playing = Arc>>>;
+
+/// The plan for one file as this person would play it, for the playlist route.
+pub async fn plan_for(ctx: &Ctx, user: i64, feed: &str, enclosure: i64) -> Result> {
+ let wanting = Wanting::load(ctx, Some(user)).await?;
+ let plans = ctx.db.plans(&[enclosure]).await?;
+ Ok(plans.get(&enclosure).and_then(|p| chosen(&wanting, user, feed, p)))
+}
+
+/// Puts a page of entries in the clock of what this person will play: for each file they play
+/// shortened, `deadair` is the seconds it saves, and the entry's position and length are the
+/// shortened version's. The page plays `/media/{id}/deadair.m3u8` for a file that has it.
pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
let wanting = Wanting::load(ctx, Some(user)).await?;
if wanting.people.is_empty() {
@@ -190,21 +266,19 @@ pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow])
.filter(|r| wanting.wants(user, &r.feed_id))
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
.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?;
+ let plans = ctx.db.plans(&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.
+ e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !plans.contains_key(&e.id) && !finished;
+ let Some(p) = plans.get(&e.id).and_then(|p| chosen(&wanting, user, &r.feed_id, p)) else { continue };
+ e.deadair = Some(p.saved_ms() / 1000);
+ // One position an item, however many files: the first played shortened sets the clock.
if !std::mem::replace(&mut converted, true) {
- r.position = to_copy(&c.cuts, r.position * 1000) / 1000;
- r.duration = r.duration.map(|d| to_copy(&c.cuts, d * 1000) / 1000);
+ r.position = to_copy(&p.cuts, r.position * 1000) / 1000;
+ r.duration = r.duration.map(|d| to_copy(&p.cuts, d * 1000) / 1000);
}
}
}
@@ -212,8 +286,8 @@ pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow])
}
/// A position and length a player saved, in the clock of what it was playing, as the
-/// original's. Past 75% of a copy, the episode also counts towards the time DeadAir has saved
-/// this person.
+/// original's. Past 75% of the shortened version, the episode also counts towards the time
+/// DeadAir has saved this person.
pub async fn on_save(
ctx: &Ctx,
playing: &Playing,
@@ -223,9 +297,9 @@ pub async fn on_save(
secs: i64,
duration: Option,
) -> Result<(i64, Option)> {
- let copy = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
- let Some(c) = copy else { return Ok((secs, duration)) };
- let original = |s: i64| to_original(&c.cuts, s * 1000) / 1000;
+ let plan = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
+ let Some(p) = plan else { return Ok((secs, duration)) };
+ let original = |s: i64| to_original(&p.cuts, s * 1000) / 1000;
let (secs, duration) = (original(secs), duration.map(original));
let len = match duration {
Some(d) => Some(d),
@@ -235,34 +309,31 @@ pub async fn on_save(
&& len > 0
&& secs * 4 >= len * 3
{
- ctx.db.count_deadair(user, feed, guid, c.saved_ms).await?;
+ ctx.db.count_deadair(user, feed, guid, p.saved_ms()).await?;
}
Ok((secs, duration))
}
-/// Makes copies until stopped: the next one anyone wants, or a wait of `IDLE` when there is
-/// none.
+/// Finds silences until stopped: in the next file anyone wants shortened, or a wait of `IDLE`
+/// when there is none.
pub async fn run(ctx: Arc, mut stop: tokio::sync::watch::Receiver) {
if !have_ffmpeg().await {
- tracing::warn!("ffmpeg is not installed, so DeadAir makes no copies");
+ tracing::warn!("ffmpeg is not installed, so DeadAir shortens nothing");
return;
}
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded
- // as the file having no copy was never tried again, even once a fix was deployed (#174).
+ // as the file having nothing to cut was never tried again, even once a fix was deployed (#174).
let mut failed = std::collections::HashSet::new();
loop {
- if let Err(e) = expire(&ctx).await {
- tracing::warn!(error = %format!("{e:#}"), "DeadAir could not remove old copies");
- }
// Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
- let made = tokio::select! {
+ let done = tokio::select! {
_ = stop.changed() => return,
- made = next(&ctx, &mut failed) => made,
+ done = next(&ctx, &mut failed) => done,
};
- match made {
+ match done {
Ok(true) => continue,
Ok(false) => {}
- Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not make a copy"),
+ Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not read a file"),
}
tokio::select! {
_ = stop.changed() => return,
@@ -272,28 +343,6 @@ pub async fn run(ctx: Arc, mut stop: tokio::sync::watch::Receiver) {
}
}
-/// Removes the copies nobody has played for `KEEP_COPY`. Their cuts stay, and playing the
-/// original puts the episode back on the list (`Db::revive_copy`).
-pub async fn expire(ctx: &Ctx) -> Result<()> {
- for (id, path) in ctx.db.stale_copies(crate::db::now() - KEEP_COPY).await? {
- // Only ever DeadAir's own file (`copy_path`): anything else would be someone's original.
- if !path.contains(".deadair") {
- continue;
- }
- match std::fs::remove_file(&path) {
- Ok(()) => {}
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
- Err(e) => {
- tracing::warn!(path, error = %e, "DeadAir could not remove an old copy");
- continue;
- }
- }
- ctx.db.drop_copy_path(id).await?;
- tracing::info!(path, "DeadAir copy removed, unplayed for a week");
- }
- Ok(())
-}
-
async fn have_ffmpeg() -> bool {
tokio::process::Command::new("ffmpeg")
.arg("-version")
@@ -303,8 +352,8 @@ async fn have_ffmpeg() -> bool {
.is_ok_and(|s| s.success())
}
-/// Makes one copy someone wants, if there is one to make. A file everyone who would play the
-/// copy has already heard through gets none: finished episodes are rarely played again.
+/// Plans one file someone wants shortened, if there is one. A file everyone who would play it
+/// shortened has already heard through gets none: finished episodes are rarely played again.
// shortcut: those stay on the list and are looked at again each pass, one query each; record
// them as passed over if a large library makes the pass slow.
async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet) -> Result {
@@ -328,87 +377,80 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet) -> Result<
}
let src = crate::download::playable(Path::new(&t.path));
let started = std::time::Instant::now();
- let made = make(&src).await;
- let (path, cuts) = match made {
- Ok(Some((path, cuts))) => (Some(path.display().to_string()), cuts),
- Ok(None) => (None, vec![]),
+ let plans = match analyse(&src).await {
+ Ok(p) => p.unwrap_or_default(),
Err(e) => {
- tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not make a copy");
+ tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not read a file");
failed.insert(t.id);
return Ok(true);
}
};
- let saved_ms = saved(&cuts);
- ctx.db.store_deadair(t.id, path.as_deref(), &serde_json::to_string(&cuts)?, saved_ms).await?;
+ ctx.db.store_plans(t.id, &serde_json::to_string(&plans)?).await?;
+ let usual = plans.get(&DEFAULT_PAUSE);
+ let saved_ms = usual.map_or(0, Plan::saved_ms);
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!(
feed = t.feed_id,
path = %src.display(),
- cuts = cuts.len(),
+ cuts = usual.map_or(0, |p| p.cuts.len()),
saved_s = saved_ms / 1000,
took_ms = started.elapsed().as_millis() as u64,
- "DeadAir copy made"
+ "DeadAir found the silences"
);
return Ok(true);
}
Ok(false)
}
-/// Makes the copy of one file. None for a file it does not apply to: video, anything but MP3
-/// and AAC, or one with no silence long enough to cut.
-pub async fn make(src: &Path) -> Result)>> {
+/// A plan for each of `PAUSES`, for one file. None for a file DeadAir does not apply to: video,
+/// anything but MP3 or AAC on its own (ADTS; AAC in MP4 has no byte ranges a playlist can
+/// use), or one with no silence long enough to cut.
+pub async fn analyse(src: &Path) -> Result >> {
let Some((first, frame)) = probe(src).await? else { return Ok(None) };
+ let path = src.display().to_string();
let log = ffmpeg(&[
- "-hide_banner", "-nostats", "-i", &src.display().to_string(), "-map", "0:a:0",
+ "-hide_banner", "-nostats", "-i", &path, "-map", "0:a:0",
"-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
])
.await
.context("finding the silences")?;
- let (frames, cuts) = plan(&parse_silences(&log), first, frame);
- if cuts.is_empty() {
- return Ok(None);
- }
- let drop: Vec = frames.iter().map(|(a, b)| format!("between(n\\,{a}\\,{b})")).collect();
- let dst = copy_path(src);
- // Written under another name and moved into place, so a copy cut short by a restart is
- // never served.
- let tmp = dst.with_file_name(format!(
- "{}.part.{}",
- dst.file_stem().unwrap_or_default().to_string_lossy(),
- dst.extension().unwrap_or_default().to_string_lossy()
- ));
- ffmpeg(&[
- "-v", "error", "-y", "-i", &src.display().to_string(), "-map", "0:a:0", "-c", "copy",
- "-bsf:a", &format!("noise=drop='{}',setts=ts=N*DURATION", any(&drop)),
- &tmp.display().to_string(),
- ])
- .await
- .context("copying the frames kept")?;
- tokio::fs::rename(&tmp, &dst).await.with_context(|| format!("moving into {}", dst.display()))?;
- Ok(Some((dst, cuts)))
+ let silences = parse_silences(&log);
+ let frames = frame_bytes(&path).await?;
+ let plans: BTreeMap = PAUSES
+ .iter()
+ .map(|&pause| {
+ let (drop, cuts) = plan(&silences, first, frame, pause as f64 / 2000.0);
+ (pause, Plan { cuts, pieces: pieces(&frames, &drop, frame) })
+ })
+ .collect();
+ Ok(plans.values().any(|p| !p.cuts.is_empty()).then_some(plans))
}
-/// Terms summed as a balanced tree, `((a+b)+(c+d))`. A plain `a+b+c+...` nests a level a term,
-/// and Debian's ffmpeg 5.1 refuses an expression nested much past 100 (#174).
-fn any(terms: &[String]) -> String {
- match terms {
- [] => "0".into(),
- [one] => one.clone(),
- _ => {
- let (a, b) = terms.split_at(terms.len() / 2);
- format!("({}+{})", any(a), any(b))
- }
- }
+/// Where each audio frame is in the file, as (byte offset, bytes), in order.
+async fn frame_bytes(path: &str) -> Result> {
+ let out = run_out("ffprobe", &[
+ "-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path,
+ ])
+ .await?;
+ // ffprobe writes the fields in its own order, not the order asked for: by name, then.
+ out.lines()
+ .map(|l| {
+ let field = |k: &str| {
+ l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::().ok())
+ };
+ field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
+ })
+ .collect()
}
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
-/// can copy; None for one it cannot. A cover picture in an MP3 is not video.
+/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
async fn probe(src: &Path) -> Result> {
let path = src.display().to_string();
let out = run_out("ffprobe", &[
- "-v", "error", "-show_entries", "stream=codec_type,codec_name:stream_disposition=attached_pic",
- "-of", "json", &path,
+ "-v", "error", "-show_entries",
+ "format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic", "-of", "json", &path,
])
.await?;
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
@@ -417,7 +459,9 @@ async fn probe(src: &Path) -> Result > {
.iter()
.any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
- if video || !audio.is_some_and(|a| a["codec_name"] == "mp3" || a["codec_name"] == "aac") {
+ let format = v["format"]["format_name"].as_str().unwrap_or("");
+ let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
+ if video || !(plain("mp3") || plain("aac")) {
return Ok(None);
}
let out = run_out("ffprobe", &[
@@ -470,18 +514,48 @@ mod tests {
#[test]
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() {
- // MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch.
+ // MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch; a 0.3 s pause.
let frame = 1152.0 / 44100.0;
- let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame);
- assert_eq!(frames, vec![(43, 110)]);
+ let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame, 0.15);
+ assert_eq!(frames, vec![(45, 108)]);
let Cut(s, e) = cuts[0];
- assert!(s >= 1100 && e <= 2900, "{cuts:?}");
- assert!(s - 1100 < 27 && 2900 - e < 27, "no more than a frame kept beyond KEEP: {cuts:?}");
- // AAC in MP4 starts a frame early, for the encoder's priming.
- let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0);
- assert_eq!(frames, vec![(49, 124)]);
+ assert!(s >= 1150 && e <= 2850, "{cuts:?}");
+ assert!(s - 1150 < 27 && 2850 - e < 27, "no more than a frame kept beyond half the pause: {cuts:?}");
+ // AAC starts a frame early, for the encoder's priming.
+ let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0, 0.15);
+ assert_eq!(frames, vec![(51, 122)]);
// A silence the margins use up entirely is left alone.
- assert!(plan(&[(1.0, 1.21)], 0.0, frame).0.is_empty());
+ assert!(plan(&[(1.0, 1.35)], 0.0, frame, 0.15).0.is_empty());
+ // A longer pause cuts less of the same silence.
+ assert!(saved(&plan(&[(1.0, 3.0)], 0.0, frame, 0.25).1) < saved(&cuts));
+ }
+
+ #[test]
+ fn pieces_are_the_frames_kept_end_to_end_and_never_too_long() {
+ // 1000 frames of 100 bytes after a 50-byte tag, an ID3 tag of 7 bytes before frame 600.
+ let frames: Vec<(u64, u64)> =
+ (0..1000u64).map(|n| (50 + n * 100 + if n >= 600 { 7 } else { 0 }, 100)).collect();
+ let frame = 0.026;
+ let p = pieces(&frames, &[(100, 199), (900, 999)], frame);
+ let at: Vec = p.iter().map(|x| x.0).collect();
+ // 0-99; 200-583 and 584-599, split at 384 frames (10 s); 600-899 after the tag.
+ assert_eq!(at, [50, 20_050, 58_450, 60_057]);
+ assert_eq!(p[0], (50, 10_000, 100.0 * frame));
+ assert_eq!(p[2].1, 1600, "frames 584-599");
+ assert!(p.iter().all(|x| x.2 <= MAX_PIECE));
+ let kept: u64 = p.iter().map(|x| x.1).sum();
+ assert_eq!(kept, 800 * 100, "every kept frame once, the tag in none");
+ }
+
+ #[test]
+ fn the_playlist_lists_each_piece_as_a_range_of_the_original() {
+ let plan = Plan { cuts: vec![Cut(1000, 2000)], pieces: vec![(50, 400, 1.0), (500, 4000, 9.5)] };
+ assert_eq!(
+ playlist(&plan, "deadair.mp3"),
+ "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:10\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n\
+ #EXTINF:1.000000,\n#EXT-X-BYTERANGE:400@50\ndeadair.mp3\n\
+ #EXTINF:9.500000,\n#EXT-X-BYTERANGE:4000@500\ndeadair.mp3\n#EXT-X-ENDLIST\n"
+ );
}
#[test]
@@ -493,7 +567,7 @@ mod tests {
assert_eq!(to_copy(&cuts, 40_000), 25_000);
for t in [0, 5_000, 10_000, 15_000, 19_999, 20_000, 25_000, 40_000] {
let back = to_original(&cuts, to_copy(&cuts, t));
- // A time inside a cut comes back as the cut's end: the copy has nothing between.
+ // A time inside a cut comes back as the cut's end: the shortened version has nothing between.
let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
assert_eq!(back, expect, "{t}");
}
@@ -511,36 +585,30 @@ mod tests {
}
#[test]
- fn the_frames_to_drop_nest_shallowly_and_fit_one_argument() {
- let terms: Vec = (0..MAX_CUTS as u64)
- .map(|i| format!("between(n\\,{}\\,{})", 1_000_000 + i * 300, 1_000_000 + i * 300 + 250))
- .collect();
- let e = any(&terms);
- let deepest = e.chars().scan(0i32, |d, c| {
- *d += match c { '(' => 1, ')' => -1, _ => 0 };
- Some(*d)
- }).max().unwrap();
- assert!(deepest <= 14, "{deepest} deep");
- assert!(e.len() < 128 * 1024, "{} bytes", e.len());
- assert_eq!(any(&terms[..3]), format!("({}+({}+{}))", terms[0], terms[1], terms[2]));
+ fn the_pause_is_the_feeds_then_its_groups_then_the_default() {
+ let w = Wanting {
+ people: HashMap::new(),
+ pauses: [((1, "a".to_string()), 500), ((1, "g".to_string()), 400)].into(),
+ groups: [("b".to_string(), "g".to_string())].into(),
+ };
+ assert_eq!(w.pause(1, "a"), 500);
+ assert_eq!(w.pause(1, "b"), 400, "its group's");
+ assert_eq!(w.pause(1, "c"), DEFAULT_PAUSE);
+ assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours");
}
- #[test]
- fn the_copy_sits_beside_the_original() {
- assert_eq!(copy_path(Path::new("/d/show/ep 1.mp3")), Path::new("/d/show/ep 1.deadair.mp3"));
- }
-
- /// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on.
- /// Skipped where ffmpeg is not installed.
+ /// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on,
+ /// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not
+ /// installed.
#[tokio::test]
- async fn a_copy_is_shorter_by_what_was_cut_and_plays() {
+ async fn the_playlist_of_a_file_is_shorter_by_what_was_cut_and_plays() {
if !have_ffmpeg().await {
eprintln!("no ffmpeg; skipped");
return;
}
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
- for (ext, codec) in [("mp3", "libmp3lame"), ("m4a", "aac")] {
+ for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
let src = dir.join(format!("t.{ext}"));
ffmpeg(&[
"-v", "error", "-y", "-f", "lavfi", "-i",
@@ -549,40 +617,56 @@ mod tests {
])
.await
.unwrap();
- let (dst, cuts) = make(&src).await.unwrap().expect("a copy");
- assert_eq!(cuts.len(), 4, "{ext}: {cuts:?}");
- // About 1.8 s from each of the four 2 s silences.
- let cut = saved(&cuts);
- assert!((7_000..7_300).contains(&cut), "{ext}: {cut}");
+ let plans = analyse(&src).await.unwrap();
+ if ext == "m4a" {
+ assert!(plans.is_none(), "AAC in MP4 has no byte ranges to play");
+ continue;
+ }
+ let plans = plans.expect("plans");
+ assert_eq!(plans.keys().copied().collect::>(), PAUSES);
+ let p = &plans[&DEFAULT_PAUSE];
+ assert_eq!(p.cuts.len(), 4, "{ext}: {:?}", p.cuts);
+ // About 1.7 s from each of the four 2 s silences, and less for a longer pause.
+ let cut = p.saved_ms();
+ assert!((6_500..6_900).contains(&cut), "{ext}: {cut}");
+ assert!(plans[&500].saved_ms() < cut && cut < plans[&200].saved_ms());
+ let list = dir.join(format!("{ext}.m3u8"));
+ std::fs::write(&list, playlist(p, &format!("t.{ext}"))).unwrap();
+ // Its frames added up: a raw AAC file's duration is only guessed from its bitrate.
let len = |p: &Path| {
let p = p.display().to_string();
async move {
- run_out("ffprobe", &["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", &p])
+ run_out("ffprobe", &["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=duration_time", "-of", "csv=p=0", &p])
.await
.unwrap()
- .trim()
- .parse::()
- .unwrap()
+ .lines()
+ .filter_map(|l| l.trim().trim_end_matches(',').parse::().ok())
+ .sum::()
}
};
- let short = len(&src).await - len(&dst).await;
+ let short = len(&src).await - len(&list).await;
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
// Decodes without a complaint.
- assert!(ffmpeg(&["-v", "error", "-i", &dst.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
+ assert!(ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
/// A real episode, for trying the cut on something other than a test tone:
-/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. The copy is left
-/// beside it to listen to.
+/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. Each pause's
+/// playlist is left beside it, `ep.mp3.300.m3u8`, to listen to.
#[cfg(test)]
#[tokio::test]
#[ignore]
async fn real_file() {
- let src = PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
+ let src = std::path::PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
let t = std::time::Instant::now();
- let (dst, cuts) = make(&src).await.unwrap().expect("a copy");
- eprintln!("{} cuts, {:.1} min saved, {:.1?} -> {}", cuts.len(), saved(&cuts) as f64 / 60_000.0, t.elapsed(), dst.display());
+ let plans = analyse(&src).await.unwrap().expect("plans");
+ eprintln!("analysed in {:.1?}", t.elapsed());
+ let name = src.file_name().unwrap().to_string_lossy().into_owned();
+ for (pause, p) in &plans {
+ std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
+ eprintln!("{pause} ms: {} cuts, {:.1} min saved, {} pieces", p.cuts.len(), p.saved_ms() as f64 / 60_000.0, p.pieces.len());
+ }
}
diff --git a/src/download.rs b/src/download.rs
index ab83b55..9bf3f4e 100644
--- a/src/download.rs
+++ b/src/download.rs
@@ -214,8 +214,6 @@ pub fn remove(path: &Path) -> std::io::Result<()> {
if path.is_dir() {
return std::fs::remove_dir_all(path);
}
- // Its DeadAir copy goes with it, if it has one; it is no use without the original (#171).
- let _ = std::fs::remove_file(crate::deadair::copy_path(path));
std::fs::remove_file(path)
}
diff --git a/src/entity.rs b/src/entity.rs
index 71a8c88..c408649 100644
--- a/src/entity.rs
+++ b/src/entity.rs
@@ -355,28 +355,21 @@ pub mod settings {
impl ActiveModelBehavior for ActiveModel {}
}
-/// DeadAir's copy of an enclosure: the same file with long silences shortened (issue #171).
-/// `cuts` is what the copy leaves out, as `[[start_ms, end_ms], ...]` of the original's time,
-/// which is what converts positions between the two. `path` is NULL for a file that cannot have
-/// one (video, or not MP3 or AAC), so it is looked at once and not every pass.
-pub mod deadair {
+/// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
+/// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
+/// AAC, nothing to cut), so that one is looked at once and not every pass.
+pub mod deadair_plans {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
- #[sea_orm(table_name = "deadair")]
+ #[sea_orm(table_name = "deadair_plans")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub enclosure_id: i64,
- #[sea_orm(column_type = "Text", nullable)]
- pub path: Option,
#[sea_orm(column_type = "Text")]
- pub cuts: String,
- pub saved_ms: i64,
- /// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy again.
+ pub plans: String,
+ /// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
pub made_at: i64,
- /// When someone last played the copy, to the hour: a copy unplayed for a week is removed
- /// (`deadair::expire`), its `path` cleared and its `cuts` kept.
- pub played_at: Option,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -398,6 +391,8 @@ pub mod deadair_prefs {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
pub wanted: bool,
+ /// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
+ pub pause_ms: Option,
}
owned_by_user!();
diff --git a/src/ipc.rs b/src/ipc.rs
index d61eca2..ace7fa7 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -81,7 +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::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
})
}
diff --git a/src/main.rs b/src/main.rs
index 2d9f0ab..ff678c3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2489,7 +2489,7 @@ mod tests {
}
#[tokio::test]
- async fn deadair_hands_out_the_copys_clock_and_saves_in_the_originals() {
+ async fn deadair_hands_out_each_persons_clock_and_saves_in_the_originals() {
let ctx = test_ctx(config::Config::default()).await;
let db = &ctx.db;
let u = db.create_user("dee", None, false).await.unwrap();
@@ -2501,8 +2501,10 @@ mod tests {
] {
db.sql(sql).await.unwrap();
}
- // Two minutes cut, both before the place saved.
- db.store_deadair(7, Some("/x/a.deadair.mp3"), "[[60000,120000],[200000,260000]]", 120_000).await.unwrap();
+ // Two minutes cut at the usual pause, both before the place saved; 40 s at the longest.
+ let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
+ "500": {"cuts": [[60000,100000]], "pieces": []}}"#;
+ db.store_plans(7, plans).await.unwrap();
db.set_position(u, "f", "g", 300, None).await.unwrap();
let order = db::order_sql("published", "desc");
let page = || {
@@ -2518,22 +2520,28 @@ mod tests {
assert!(!off.enclosures[0].deadair_pending);
db.set_deadair(u, "f", Some(true)).await.unwrap();
- db.sql("DELETE FROM deadair").await.unwrap();
+ db.sql("DELETE FROM deadair_plans").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();
+ assert!(waiting.enclosures[0].deadair_pending, "on, and its silences not found yet");
+ db.store_plans(7, plans).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)));
+ // A longer pause of their own: their clock, and the playlist, are that plan's (#182).
+ db.set_deadair_pause(u, "f", 500).await.unwrap();
+ let long = page().await;
+ assert_eq!((long.position, long.duration, long.enclosures[0].deadair), (260, Some(560), Some(40)));
+ assert_eq!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap().saved_ms(), 40_000);
+ db.set_deadair_pause(u, "f", 300).await.unwrap();
- // Saved from the copy at 3:20 of its 8:00: 5:20 of the original's 10:00, short of 75%.
+ // Saved from the playlist at 3:20 of its 8:00: 5:20 of the original's 10:00, short of 75%.
let playing = deadair::Playing::default();
- let copy = db.copies(&[7]).await.unwrap().remove(0);
- playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(copy));
+ let plan = deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
+ playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
let saved = deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(480)).await.unwrap();
assert_eq!(saved, (320, Some(600)));
assert_eq!(db.deadair_total(u).await.unwrap(), 0);
- // Past 75% (6:00 of the copy is 8:00 of the original), counted once.
+ // Past 75% (6:00 of the playlist is 8:00 of the original), counted once.
for _ in 0..2 {
deadair::on_save(&ctx, &playing, u, "f", "g", 360, None).await.unwrap();
}
@@ -2541,51 +2549,10 @@ mod tests {
// Playing the original again: saved as it is.
playing.lock().unwrap().clear();
assert_eq!(deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(600)).await.unwrap(), (200, Some(600)));
-
- // Turned off again: the original's clock.
+ // Turned off again: the original's clock, and no playlist.
db.set_deadair(u, "f", Some(false)).await.unwrap();
assert_eq!(page().await.enclosures[0].deadair, None);
- }
-
- #[tokio::test]
- async fn deadair_removes_a_copy_unplayed_for_a_week_and_makes_it_again_when_played() {
- let ctx = test_ctx(config::Config::default()).await;
- let db = &ctx.db;
- let dir = std::env::temp_dir().join(format!("ipx-deadair-ttl-{}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
- let (orig, copy) = (dir.join("a.mp3"), dir.join("a.deadair.mp3"));
- std::fs::write(&orig, "original").unwrap();
- std::fs::write(©, "copy").unwrap();
- db.sql("INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f', 'g', 1)").await.unwrap();
- db.sql(&format!(
- "INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
- VALUES (7, 'f', 'g', 'http://x/a.mp3', '{}', 'done', 100)",
- orig.display()
- ))
- .await
- .unwrap();
- db.store_deadair(7, Some(©.display().to_string()), "[[60000,120000]]", 60_000).await.unwrap();
- let on_list = || async { db.deadair_todo().await.unwrap().iter().any(|t| t.id == 7) };
-
- deadair::expire(&ctx).await.unwrap();
- assert!(copy.exists(), "made just now");
- db.sql("UPDATE deadair SET made_at = 100").await.unwrap();
- db.deadair_played(7).await.unwrap();
- deadair::expire(&ctx).await.unwrap();
- assert!(copy.exists(), "made long ago, but played just now");
-
- db.sql("UPDATE deadair SET played_at = 0").await.unwrap();
- deadair::expire(&ctx).await.unwrap();
- assert!(!copy.exists() && orig.exists(), "the copy goes, the original stays");
- assert!(db.copies(&[7]).await.unwrap().is_empty());
- assert!(!on_list().await, "not made again until someone plays it");
- assert!(db.revive_copy(7).await.unwrap());
- assert!(on_list().await, "played: made again");
-
- // A file with no copy because it cannot have one is never put back on the list.
- db.store_deadair(7, None, "[]", 0).await.unwrap();
- assert!(!db.revive_copy(7).await.unwrap());
- std::fs::remove_dir_all(&dir).unwrap();
+ assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
}
#[tokio::test]
diff --git a/src/retention.rs b/src/retention.rs
index b888ec7..fb779ae 100644
--- a/src/retention.rs
+++ b/src/retention.rs
@@ -61,11 +61,7 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result {
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
}
- let mut candidates = db.reap_candidates().await?;
- // A file's DeadAir copy is on the disk too, and goes when it goes (#171).
- for c in &mut candidates {
- c.bytes += copy_bytes(&c.path) as i64;
- }
+ let candidates = db.reap_candidates().await?;
if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
@@ -104,7 +100,7 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result {
let size = std::fs::metadata(&c.path)
.ok()
.filter(|m| m.is_file())
- .map_or(c.bytes.max(0) as u64, |m| m.len() + copy_bytes(&c.path));
+ .map_or(c.bytes.max(0) as u64, |m| m.len());
// Missing is fine: the point is that it is gone.
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
&& e.kind() != std::io::ErrorKind::NotFound
@@ -116,10 +112,6 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result {
Ok(size)
}
-fn copy_bytes(path: &str) -> u64 {
- std::fs::metadata(crate::deadair::copy_path(std::path::Path::new(path))).map_or(0, |m| m.len())
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/web.rs b/src/web.rs
index a15f8b2..9c83fef 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -69,7 +69,8 @@ pub fn router(state: WebState) -> Router {
.route("/admin", get(admin_page))
.route("/admin.js", get(admin_js))
.route("/media/{id}", get(media))
- .route("/media/{id}/deadair", get(media_deadair))
+ .route("/media/{id}/deadair.m3u8", get(media_deadair))
+ .route("/media/{id}/deadair.mp3", get(media_piece))
.route("/api/art", get(art))
.layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer.
@@ -87,6 +88,7 @@ pub fn router(state: WebState) -> Router {
.route("/app.js", get(app_js))
.route("/app.css", get(app_css))
.route("/login.js", get(login_js))
+ .route("/hls.js", get(hls_js))
.route("/inter.woff2", get(inter))
.route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(access_log))
@@ -642,6 +644,12 @@ async fn login_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
}
+/// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181): the page loads
+/// it only there, by the name build.mjs gives it.
+async fn hls_js() -> impl IntoResponse {
+ script(include_str!(concat!(env!("OUT_DIR"), "/hls.js")))
+}
+
fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
}
@@ -773,8 +781,10 @@ struct FeedRow {
keywords: Vec,
/// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec,
- /// Played from DeadAir's copies: on for this feed or its group, off until you turn it on.
+ /// Played shortened by DeadAir: on for this feed or its group, off until you turn it on.
deadair: bool,
+ /// The pause DeadAir leaves where it cuts, in ms: this feed's, its group's, or the usual.
+ deadair_pause: i64,
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option,
@@ -839,8 +849,10 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
.collect();
let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?;
let pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?;
- let deadair: std::collections::HashMap =
- state.ctx.db.deadair_prefs(Some(user.id)).await?.into_iter().map(|(_, f, w)| (f, w)).collect();
+ let prefs = state.ctx.db.deadair_prefs(Some(user.id)).await?;
+ let deadair: std::collections::HashMap<&str, bool> = prefs.iter().map(|(_, f, w, _)| (f.as_str(), *w)).collect();
+ let pauses: std::collections::HashMap<&str, i64> =
+ prefs.iter().filter_map(|(_, f, _, p)| Some((f.as_str(), (*p)?))).collect();
let mut listed = state.ctx.db.feed_list(user.id, only).instrument(step("feed_list")).await?;
let mut out = Vec::with_capacity(mine.len());
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
@@ -865,7 +877,16 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
.or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()),
blocked,
- deadair: deadair.get(id).or_else(|| feed.group.as_deref().and_then(|g| deadair.get(g))).copied().unwrap_or(false),
+ deadair: deadair
+ .get(id.as_str())
+ .or_else(|| feed.group.as_deref().and_then(|g| deadair.get(g)))
+ .copied()
+ .unwrap_or(false),
+ deadair_pause: pauses
+ .get(id.as_str())
+ .or_else(|| feed.group.as_deref().and_then(|g| pauses.get(g)))
+ .copied()
+ .unwrap_or(crate::deadair::DEFAULT_PAUSE),
allow_explicit: mine
.allow_explicit
.or(up.and_then(|u| u.allow_explicit))
@@ -1577,10 +1598,12 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option>,
pinned: Option,
- /// True plays the feed from DeadAir's copies, false keeps it out (of its group's too), null
+ /// True plays the feed shortened by DeadAir, false keeps it out (of its group's too), null
/// follows its group again (#171).
#[serde(default, deserialize_with = "double_option")]
deadair: Option>,
+ /// The pause DeadAir leaves where it cuts, in ms: one of `deadair::PAUSES` (#182).
+ deadair_pause: Option,
}
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
@@ -1618,10 +1641,18 @@ async fn patch_feed(
{
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
+ if let Some(p) = body.deadair_pause
+ && !crate::deadair::PAUSES.contains(&p)
+ {
+ return Err(ApiError::bad_request("DeadAir's pause is 200, 300, 400 or 500 ms"));
+ }
if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).await?;
state.ctx.deadair.notify_one();
}
+ if let Some(p) = body.deadair_pause {
+ state.ctx.db.set_deadair_pause(user.id, &id, p).await?;
+ }
// What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change.
if let Some(mut mine) = state.ctx.db.subscription(user.id, &id).await? {
@@ -1918,9 +1949,32 @@ async fn media(
serve_media(state, id, user, req, false).await
}
-/// DeadAir's copy of a file (#171), at an address of its own: a player that had part of one
-/// version cached and asked the same address for more would stitch the two together.
-async fn media_deadair(
+/// DeadAir's playlist of a file (#181): the original's frames kept by this person's pause
+/// (#182), as byte ranges of `deadair.mp3` beside it. At an address of its own, so a player that
+/// had part of one version cached never stitches the two together.
+async fn media_deadair(State(state): State, Path(id): Path, user: crate::db::User) -> Response {
+ let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
+ return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
+ };
+ if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
+ return e.into_response();
+ }
+ let plan = match crate::deadair::plan_for(&state.ctx, user.id, &enc.feed_id, id).await {
+ Ok(Some(p)) => p,
+ Ok(None) => return (StatusCode::NOT_FOUND, "DeadAir has nothing to cut in this file for you").into_response(),
+ Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
+ };
+ let body = crate::deadair::playlist(&plan, "deadair.mp3");
+ // Which version this person is playing, so the positions their player saves are read in its
+ // clock (`deadair::on_save`).
+ state.playing.lock().unwrap().insert((user.id, enc.feed_id, enc.guid), Arc::new(plan));
+ // Never kept: it changes with the pause they choose.
+ ([(header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), (header::CACHE_CONTROL, "no-store")], body)
+ .into_response()
+}
+
+/// The original's bytes, for the pieces of DeadAir's playlist.
+async fn media_piece(
State(state): State,
Path(id): Path,
user: crate::db::User,
@@ -1929,32 +1983,20 @@ async fn media_deadair(
serve_media(state, id, user, req, true).await
}
-async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Request, copy: bool) -> Response {
+async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Request, piece: bool) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
- let Some(mut path) = enc.path else {
+ let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
- // Which version this person is playing, so the positions their player saves are read in its
- // clock (`deadair::on_save`).
- let key = (user.id, enc.feed_id.clone(), enc.guid.clone());
- if copy {
- let Some(c) = state.ctx.db.copies(&[id]).await.ok().and_then(|c| c.into_iter().next()) else {
- return (StatusCode::NOT_FOUND, "no DeadAir copy").into_response();
- };
- path = c.path.clone();
- state.playing.lock().unwrap().insert(key, Arc::new(c));
- let _ = state.ctx.db.deadair_played(id).await;
- } else {
- state.playing.lock().unwrap().remove(&key);
- // A copy removed for going unplayed (#179) is made again once someone plays the episode.
- if state.ctx.db.revive_copy(id).await.unwrap_or(false) {
- state.ctx.deadair.notify_one();
- }
+ // The original itself: positions this person's player saves are in its clock again. A piece
+ // of the playlist leaves that as the playlist set it.
+ if !piece {
+ state.playing.lock().unwrap().remove(&(user.id, enc.feed_id.clone(), enc.guid.clone()));
}
match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(r) => r.into_response(),
diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js
index 209640a..d7406a6 100644
--- a/tests/ui/app.spec.js
+++ b/tests/ui/app.spec.js
@@ -1751,7 +1751,7 @@ test('a feed\'s settings say what downloads, take words as chips, and keep what
await page.keyboard.press('Escape');
});
-test('DeadAir is off until turned on for a show, then plays its shortened copy and switches back', async ({ page }) => {
+test('DeadAir is off until turned on for a show, then plays it shortened, with your pause, and switches back', async ({ page }) => {
// #171/#172. A show of its own, with real audio, added here and left at the end.
const { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
const entry = async () => {
@@ -1761,7 +1761,7 @@ test('DeadAir is off until turned on for a show, then plays its shortened copy a
await expect.poll(async () => (await entry())?.guid, { timeout: 15_000 }).toBe('quiet-1');
await page.request.post(`/api/feeds/${encodeURIComponent(id)}/download-latest`, { data: { count: 1 } });
await expect.poll(async () => !!(await entry())?.enclosures[0]?.path, { timeout: 15_000 }).toBe(true);
- // Off for a show until someone turns it on: no copy is made.
+ // Off for a show until someone turns it on: nothing is cut.
expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.reload();
@@ -1772,16 +1772,26 @@ 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, 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.
+ // Found 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.7 s off each of four 2 s silences.
const row = page.locator('.ep', { hasText: 'Pauses Between' });
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.
+ // A longer pause of your own (#182) saves less.
+ const usual = (await entry()).enclosures[0].deadair;
+ await page.locator('#content .acts [data-a="settings"]').click();
+ await expect(page.locator('[name=sdpause][value="300"]')).toBeChecked();
+ await page.locator('[name=sdpause][value="500"]').locator('xpath=..').click();
+ await page.locator('#ssave').click();
+ await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
+
+ // Played through DeadAir's playlist (#181), by hls.js in Chromium, with the switch beside the
+ // speed pressed. The element's own src is then hls.js's blob: address.
await row.locator('[data-a=play]').click();
- await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/deadair$/);
+ await expect.poll(() => page.evaluate(() => player.src)).toMatch(/\/deadair\.m3u8$/);
+ await expect.poll(() => page.evaluate(() => document.querySelector('#audio').currentTime), { timeout: 15_000 }).toBeGreaterThan(0.5);
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
// Off from the player: the original, from the same place.
await page.locator('#pdead').click();
diff --git a/web/build.mjs b/web/build.mjs
index fbc892c..54f21fb 100644
--- a/web/build.mjs
+++ b/web/build.mjs
@@ -30,6 +30,11 @@ const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
+// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
+// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
+const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
+const HLS_URL = `/hls.js?v=${hash(HLS)}`;
+
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
@@ -51,7 +56,8 @@ export function buildStyle({ minify = true } = {}) {
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
- const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
+ const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
+ .replaceAll('__HLS_JS__', HLS_URL);
const js = swc.transformSync(src, {
filename: name + '.ts',
jsc: {
@@ -88,6 +94,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
+ fs.writeFileSync(path.join(out, 'hls.js'), HLS);
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);
diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts
index a5714da..863e590 100644
--- a/web/src/dialogs.ts
+++ b/web/src/dialogs.ts
@@ -530,7 +530,11 @@ function settingsModal(f, newUrl?: string){
${sw(`
`,'DeadAir',' lead')}
Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
- are part of the telling.
+ are part of the telling.
+ Pause it leaves
+ ${[200,300,400,500].map(ms=>
+ ` ${ms/1000} s `).join('')}
+ A longer pause sounds more relaxed and saves less time.
${isGroup?'':`Hide items that mention
${chipsHTML(f.blocked||[],'sblockin')}
Whole words or phrases. Hidden from your list and not downloaded for you, along
@@ -614,7 +618,8 @@ function settingsModal(f, newUrl?: string){
const patch: Record={
keywords:chipWords($('#skw')),
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
- auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked};
+ auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
+ deadair_pause:Number($('[name=sdpause]:checked').value)};
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
// API refuses it from anyone else. The folder and category are not this dialog's at all.
diff --git a/web/src/player.ts b/web/src/player.ts
index 0cdbddc..f09ec05 100644
--- a/web/src/player.ts
+++ b/web/src/player.ts
@@ -3,9 +3,34 @@ const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
-/// Where a file plays from: DeadAir's copy when the server says this person plays it (#171),
-/// whose times the entry is already given in, else the file itself.
-const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair`:`/media/${enc.id}`;
+/// Where a file plays from: DeadAir's playlist when the server says this person plays it
+/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
+const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
+
+/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
+/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
+/// on the playlist's MP3 ranges with "stream parsing failed", and so plays nothing.
+let hls: any=null;
+function dropHls(){ if(hls){ hls.destroy(); hls=null; } }
+/// Puts a file in the player: null when it is there at once, else a promise to wait on before
+/// playing, while hls.js loads. Safari always takes the first way, where play() has to run within
+/// the tap itself or it is refused.
+function setSrc(url: string): Promise|null{
+ dropHls();
+ const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl');
+ if(!url.endsWith('.m3u8')||native){ audio.src=url; return null; }
+ const w=window as any;
+ const ready=w.Hls?Promise.resolve():new Promise((ok,fail)=>{
+ const s=document.createElement('script');
+ s.src='__HLS_JS__'; s.onload=()=>ok(); s.onerror=()=>fail(new Error('the DeadAir player did not load'));
+ document.head.append(s);
+ });
+ return ready.then(()=>{
+ hls=new w.Hls();
+ hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal) toast('Playback failed: '+d.details,true); });
+ hls.loadSource(url); hls.attachMedia(audio);
+ });
+}
// Marked read when an item has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the item vanish from the Unread list the instant
@@ -29,10 +54,11 @@ function play(e,enc=e.enclosures.find(isPlayable)){
const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc);
// Every play button is a pause button for what is playing, as the player bar's is.
if(resuming && !audio.paused){ audio.pause(); return; }
+ let wait: Promise|null=null;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
- audio.src=player.src=mediaSrc(enc);
+ wait=setSrc(player.src=mediaSrc(enc));
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
@@ -45,7 +71,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
player.marked=false;
paintDead();
}
- audio.play().catch(err=>toast('Playback failed: '+err.message,true));
+ const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true));
+ if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
renderEntries();
}
function mediaSession(e,f){
@@ -153,12 +180,12 @@ async function switchVersion(){
if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused;
player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position;
- audio.src=player.src;
+ const wait=setSrc(player.src);
audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
- if(was) audio.play().catch(()=>{});
+ if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
return true;
}
-$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
+$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};
(function restore(){