DeadAir plays an HLS playlist of the original, with a pause of your own (#181, #182)

DeadAir made a shortened copy of each episode with ffmpeg: as much disk
again as the original, one pause length for everyone, and a minutes-long
wait after the download. Seeking past silences instead was tried on an
iPhone and left about 1.3 s of nothing at every cut (#178).

Now it finds a file's silences once (silencedetect, plus ffprobe for
each frame's byte position) and stores a plan for each pause a person
can choose, 0.2 to 0.5 s: the cuts, and the frames kept as byte ranges
of the original. /media/{id}/deadair.m3u8 hands this person's plan out
as an HLS playlist of ranges of /media/{id}/deadair.mp3, the original.
Tested on iOS Safari first (#180): no gap at the cuts, plays locked,
seeks exactly, and needs no ID3 timestamp in front of each piece.

Other browsers get hls.js (1.6.19, the light build), loaded only when
needed. Not where canPlayType says it can play HLS: Chromium answers
"maybe" and then fails on MP3 ranges with "stream parsing failed", so
only Apple's browsers play the playlist themselves.

The pause is per person and per show, on deadair_prefs.pause_ms, 0.3 s
unless chosen; 0.2 s, what the copies left, made speech sound rushed.
AAC in MP4 has no ranges a playlist can use, so only MP3 and ADTS AAC
are shortened (the library is 803 MP3s and no .m4a). The copies, their
table and #179's expiry go: the first start drops the table and deletes
the files it names, only those named name.deadair.*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 19:46:01 +00:00
parent f29d493a0e
commit 089202f72b
15 changed files with 564 additions and 440 deletions

205
src/db.rs
View File

@@ -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::<String>("", "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<crate::deadair::Cut>,
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<i64>) -> Result<Vec<(i64, String, bool)>> {
/// 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<i64>) -> Result<Vec<(i64, String, bool, Option<i64>)>> {
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<Vec<DeadAirTodo>> {
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<Vec<(i64, String)>> {
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<bool> {
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<Vec<Copy>> {
if enclosure_ids.is_empty() {
return Ok(vec![]);
}
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, 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<std::collections::HashSet<i64>> {
/// 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<std::collections::HashMap<i64, std::collections::BTreeMap<i64, crate::deadair::Plan>>> {
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)",
"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, "copy").unwrap();
db.orm.execute_unprepared("CREATE TABLE deadair (enclosure_id BIGINT PRIMARY KEY, path TEXT)").await.unwrap();
for (id, path) in [(1, &copy), (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::<Vec<_>>(), [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();