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

View File

@@ -7,10 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### Changed
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177). - 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 ## [0.11.0] - 2026-10-09

9
package-lock.json generated
View File

@@ -7,7 +7,8 @@
"name": "ipx-web", "name": "ipx-web",
"dependencies": { "dependencies": {
"@swc/core": "^1.16.2", "@swc/core": "^1.16.2",
"@swc/html": "^1.16.2" "@swc/html": "^1.16.2",
"hls.js": "1.6.19"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.0", "@playwright/test": "^1.56.0",
@@ -871,6 +872,12 @@
"node": ">=16.20.0" "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": { "node_modules/playwright": {
"version": "1.63.0", "version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",

View File

@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@swc/core": "^1.16.2", "@swc/core": "^1.16.2",
"@swc/html": "^1.16.2" "@swc/html": "^1.16.2",
"hls.js": "1.6.19"
} }
} }

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(blocklists::Entity),
schema.create_table_from_entity(hidden::Entity), schema.create_table_from_entity(hidden::Entity),
schema.create_table_from_entity(api_tokens::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_prefs::Entity),
schema.create_table_from_entity(deadair_saved::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())?; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
} }
} }
if orm.execute_unprepared("SELECT played_at FROM deadair LIMIT 0").await.is_err() { if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
let sql = "ALTER TABLE deadair ADD COLUMN played_at BIGINT"; let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; 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(()) Ok(())
} }
@@ -2257,28 +2273,21 @@ pub struct DeadAirTodo {
pub path: String, 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 { impl Db {
/// DeadAir settings as (person, feed, wanted), everyone's or one person's. An empty feed is /// DeadAir settings as (person, feed, wanted, pause in ms), everyone's or one person's. An
/// the switch beside the speed. /// empty feed is the switch beside the speed.
pub async fn deadair_prefs(&self, user_id: Option<i64>) -> Result<Vec<(i64, String, bool)>> { pub async fn deadair_prefs(&self, user_id: Option<i64>) -> Result<Vec<(i64, String, bool, Option<i64>)>> {
let mut a = Args::default(); let mut a = Args::default();
let only = match user_id { let only = match user_id {
Some(u) => format!("WHERE user_id = {}", a.p(u)), Some(u) => format!("WHERE user_id = {}", a.p(u)),
None => String::new(), 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? .await?
.iter() .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() .collect()
} }
@@ -2301,11 +2310,22 @@ impl Db {
Ok(()) 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>> { pub async fn deadair_todo(&self) -> Result<Vec<DeadAirTodo>> {
self.rows( self.rows(
"SELECT e.id, e.feed_id, e.guid, e.path FROM enclosures e "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' 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)) AND (d.enclosure_id IS NULL OR d.made_at < coalesce(e.downloaded_at, 0))
ORDER BY e.id", ORDER BY e.id",
@@ -2356,104 +2376,42 @@ impl Db {
.collect() .collect()
} }
/// Records the copy made of an enclosure, or with no path that it has none. /// Records what DeadAir found in an enclosure: its plans as JSON, `{}` for none.
pub async fn store_deadair(&self, enclosure_id: i64, path: Option<&str>, cuts: &str, saved_ms: i64) -> Result<()> { pub async fn store_plans(&self, enclosure_id: i64, plans: &str) -> Result<()> {
self.exec( self.exec(
"INSERT INTO deadair (enclosure_id, path, cuts, saved_ms, made_at) VALUES ($1, $2, $3, $4, $5) "INSERT INTO deadair_plans (enclosure_id, plans, made_at) VALUES ($1, $2, $3)
ON CONFLICT (enclosure_id) DO UPDATE SET path = excluded.path, cuts = excluded.cuts, ON CONFLICT (enclosure_id) DO UPDATE SET plans = excluded.plans, made_at = excluded.made_at",
saved_ms = excluded.saved_ms, made_at = excluded.made_at", vec![enclosure_id.into(), plans.into(), now().into()],
vec![enclosure_id.into(), path.map(str::to_owned).into(), cuts.into(), saved_ms.into(), now().into()],
) )
.await?; .await?;
Ok(()) Ok(())
} }
/// Notes that someone is playing a copy, so it is kept. At most once an hour: the player asks /// DeadAir's plans for these enclosures, by pause, for each one it has looked at since it
/// for more of the file at every seek. /// was last downloaded and is still on disk. Empty for a file it does not apply to; missing
pub async fn deadair_played(&self, enclosure_id: i64) -> Result<()> { /// for one it has yet to look at.
let now = now(); pub async fn plans(
self.exec( &self,
"UPDATE deadair SET played_at = $1 WHERE enclosure_id = $2 AND coalesce(played_at, 0) < $3", enclosure_ids: &[i64],
vec![now.into(), enclosure_id.into(), (now - 3600).into()], ) -> Result<std::collections::HashMap<i64, std::collections::BTreeMap<i64, crate::deadair::Plan>>> {
)
.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>> {
if enclosure_ids.is_empty() { if enclosure_ids.is_empty() {
return Ok(Default::default()); return Ok(Default::default());
} }
let mut a = Args::default(); let mut a = Args::default();
let ids: Vec<String> = enclosure_ids.iter().map(|&i| a.p(i)).collect(); let ids: Vec<String> = enclosure_ids.iter().map(|&i| a.p(i)).collect();
let sql = format!( let sql = format!(
"SELECT d.enclosure_id FROM deadair d JOIN enclosures e ON e.id = d.enclosure_id "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 d.made_at >= coalesce(e.downloaded_at, 0)", WHERE d.enclosure_id IN ({}) AND e.path IS NOT NULL AND d.made_at >= coalesce(e.downloaded_at, 0)",
ids.join(", ") 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 /// Counts an episode towards the time DeadAir has saved someone, once however often it is
@@ -2507,13 +2465,40 @@ mod tests {
use super::*; use super::*;
#[tokio::test] #[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 db = Db::memory().await.unwrap();
let u = db.create_user("dee", None, false).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, "f", Some(true)).await.unwrap();
db.set_deadair(u, "g", Some(false)).await.unwrap(); db.set_deadair(u, "g", Some(false)).await.unwrap();
db.set_deadair(u, "g", None).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 [ for sql in [
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at) "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)", 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(); let todo = db.deadair_todo().await.unwrap();
assert_eq!(todo.iter().map(|t| t.id).collect::<Vec<_>>(), [1], "only a file on disk"); 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()); assert!(db.deadair_todo().await.unwrap().is_empty());
let c = db.copies(&[1, 2]).await.unwrap(); let p = db.plans(&[1, 2]).await.unwrap();
assert_eq!((c.len(), c[0].cuts.as_slice(), c[0].saved_ms), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000)); 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: the copy is stale until made again. // 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(); 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_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()); assert!(!db.subscribed_any(&[u], "f").await.unwrap());
db.subscribe(u, "f").await.unwrap(); db.subscribe(u, "f").await.unwrap();

View File

@@ -1,18 +1,21 @@
//! DeadAir (issue #171): podcasts play from a copy with their long silences shortened, so they //! DeadAir (issue #171): podcasts play with their long silences shortened, so they finish
//! finish sooner without sounding sped up. //! sooner without sounding sped up.
//! //!
//! The copy is the original's own frames with the silent ones left out, copied by ffmpeg with //! Nothing is copied (#181). DeadAir finds a file's silences once, in the background, and keeps a
//! nothing re-encoded: about 10 s an hour of audio on one core, finding the silences included. //! `Plan` for each pause length a person can choose (#182): the frames kept, as byte ranges of
//! It is made in the background, one file at a time, for files in feeds someone has DeadAir on //! the original. The player gets an HLS playlist of those ranges (`playlist`), which Safari plays
//! for, and kept beside the original (`copy_path`) until `download::remove` takes both. //! 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 //! 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 //! Android apps save positions themselves, from their own player, so converting in the page
//! would miss them. //! would miss them.
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf}; use std::fmt::Write as _;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result, bail}; 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. /// issue used. A podcast's room tone sits well below it and speech well above.
const THRESHOLD: &str = "-38dB"; const THRESHOLD: &str = "-38dB";
const MIN_SILENCE: f64 = 0.35; 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 /// 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, /// 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. /// which nobody hears between two silent frames and everyone hears at the start of a word.
const KEEP: f64 = 0.1; pub const PAUSES: [i64; 4] = [200, 300, 400, 500];
/// ffmpeg takes the frames to drop as one argument, and Linux refuses an argument over 128 KiB: /// 0.2 s, the first DeadAir's, made speech sound rushed.
/// 3500 of them, numbered into the millions, come to about 110 KiB. pub const DEFAULT_PAUSE: i64 = 300;
// shortcut: a file with more silences than this keeps only its longest; about 7 hours of /// The longest piece of a playlist, in seconds: about what HLS players expect, so a long stretch
// conversation at the rate measured. Pass the list some other way if a feed ever gets there. /// of talk is not one request for megabytes.
const MAX_CUTS: usize = 3500; 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 /// 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. /// picked up this long after, at most.
const IDLE: std::time::Duration = std::time::Duration::from_secs(120); 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cut(pub i64, pub i64); pub struct Cut(pub i64, pub i64);
/// Where the copy of `original` goes: beside it, `name.deadair.mp3` for `name.mp3`. /// What one pause length leaves of a file: what it cuts, and what it keeps as pieces of the
pub fn copy_path(original: &Path) -> PathBuf { /// original, (byte offset, bytes, seconds).
let stem = original.file_stem().map(|s| s.to_string_lossy()).unwrap_or_default(); #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
let name = match original.extension() { pub struct Plan {
Some(ext) => format!("{stem}.deadair.{}", ext.to_string_lossy()), pub cuts: Vec<Cut>,
None => format!("{stem}.deadair"), pub pieces: Vec<(u64, u64, f64)>,
}; }
original.with_file_name(name)
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 /// 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<f64> {
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same /// 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 /// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
/// silence, `KEEP` in from each end, go. /// silence, `keep` seconds in from each end, go.
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64) -> (Vec<(u64, u64)>, Vec<Cut>) { pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64, keep: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
let mut spans: Vec<(u64, u64)> = silences let spans: Vec<(u64, u64)> = silences
.iter() .iter()
.filter(|(s, e)| e - s >= MIN_SILENCE) .filter(|(s, e)| e - s >= MIN_SILENCE)
.filter_map(|&(s, e)| { .filter_map(|&(s, e)| {
let a = ((s + KEEP - first) / frame).ceil().max(0.0) as u64; let a = ((s + keep - first) / frame).ceil().max(0.0) as u64;
let b = ((e - KEEP - first) / frame).floor() as i64 - 1; let b = ((e - keep - first) / frame).floor() as i64 - 1;
(b >= a as i64).then_some((a, b as u64)) (b >= a as i64).then_some((a, b as u64))
}) })
.collect(); .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 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(); let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
(spans, cuts) (spans, cuts)
} }
/// A time in the original, in ms, as the same moment in the copy. A time inside a cut is where /// The frames kept, as pieces of the file: runs of frames lying end to end in it, none past
/// the cut was made. /// `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 { pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms; let mut out = ms;
for &Cut(s, e) in cuts { for &Cut(s, e) in cuts {
@@ -117,7 +162,7 @@ pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
out 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 { pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms; let mut out = ms;
for &Cut(s, e) in cuts { 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() 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 /// Whether someone with these settings plays a feed shortened. Off for every feed until they
/// they turn it on for that one, or for the group it came in (an OPML or a Patreon creator), /// turn it on for that one, or for the group it came in (an OPML or a Patreon creator), which a
/// which a feed of the group can still say otherwise to. /// feed of the group can still say otherwise to.
pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool { pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool {
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false) 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, /// 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 struct Wanting {
pub people: HashMap<i64, HashMap<String, bool>>, pub people: HashMap<i64, HashMap<String, bool>>,
pub pauses: HashMap<(i64, String), i64>,
pub groups: HashMap<String, String>, pub groups: HashMap<String, String>,
} }
impl Wanting { impl Wanting {
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> { pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
let mut people: HashMap<i64, HashMap<String, bool>> = HashMap::new(); let mut people: HashMap<i64, HashMap<String, bool>> = 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.entry(user).or_default().insert(feed, wanted);
} }
people.retain(|_, p| p.values().any(|&w| w)); people.retain(|_, p| p.values().any(|&w| w));
@@ -160,7 +210,7 @@ impl Wanting {
} else { } else {
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect() 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 { pub fn wants(&self, user: i64, feed: &str) -> bool {
@@ -168,18 +218,44 @@ impl Wanting {
.get(&user) .get(&user)
.is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str))) .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 /// The plan someone plays a file with: their pause's, for a file in a feed they have DeadAir on
/// them a copy and cleared when it serves the original, so a saved position is converted from /// for, once its silences are found. None if it cuts nothing.
/// the clock it was measured in, whether the page saved it or a native app did. fn chosen(wanting: &Wanting, user: i64, feed: &str, plans: &BTreeMap<i64, Plan>) -> Option<Plan> {
// shortcut: in memory, so a restart forgets it. A position saved before the player next asks if !wanting.wants(user, feed) {
// for more of the copy is then taken as the original's, which resumes a little early, never late. return None;
pub type Playing = Arc<std::sync::Mutex<HashMap<(i64, String, String), Arc<crate::db::Copy>>>>; }
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 /// What each person is playing shortened, by (person, feed, guid): set when they are handed the
/// copy they play, `deadair` is the seconds it saves, and the entry's position and length are /// playlist and cleared when `/media` serves them the original, so a saved position is converted
/// the copy's. The page plays `/media/{id}/deadair` for a file that has it. /// 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<std::sync::Mutex<HashMap<(i64, String, String), Arc<Plan>>>>;
/// 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<Option<Plan>> {
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<()> { pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
let wanting = Wanting::load(ctx, Some(user)).await?; let wanting = Wanting::load(ctx, Some(user)).await?;
if wanting.people.is_empty() { 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)) .filter(|r| wanting.wants(user, &r.feed_id))
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id)) .flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
.collect(); .collect();
let copies: HashMap<i64, crate::db::Copy> = let plans = ctx.db.plans(&ids).await?;
ctx.db.copies(&ids).await?.into_iter().map(|c| (c.enclosure_id, c)).collect();
let done = ctx.db.deadair_done(&ids).await?;
for r in rows.iter_mut() { for r in rows.iter_mut() {
// Heard through already: the maker passes it over, so nothing is coming. // 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 finished = r.duration.is_some_and(|d| d > 0 && r.position * 10 >= d * 9);
let mut converted = false; let mut converted = false;
for e in &mut r.enclosures { for e in &mut r.enclosures {
e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !done.contains(&e.id) && !finished; e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !plans.contains_key(&e.id) && !finished;
let Some(c) = copies.get(&e.id) else { continue }; let Some(p) = plans.get(&e.id).and_then(|p| chosen(&wanting, user, &r.feed_id, p)) else { continue };
e.deadair = Some(c.saved_ms / 1000); e.deadair = Some(p.saved_ms() / 1000);
// One position an item, however many files: the first with a copy sets the clock. // One position an item, however many files: the first played shortened sets the clock.
if !std::mem::replace(&mut converted, true) { if !std::mem::replace(&mut converted, true) {
r.position = to_copy(&c.cuts, r.position * 1000) / 1000; r.position = to_copy(&p.cuts, r.position * 1000) / 1000;
r.duration = r.duration.map(|d| to_copy(&c.cuts, d * 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 /// 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 /// original's. Past 75% of the shortened version, the episode also counts towards the time
/// this person. /// DeadAir has saved this person.
pub async fn on_save( pub async fn on_save(
ctx: &Ctx, ctx: &Ctx,
playing: &Playing, playing: &Playing,
@@ -223,9 +297,9 @@ pub async fn on_save(
secs: i64, secs: i64,
duration: Option<i64>, duration: Option<i64>,
) -> Result<(i64, Option<i64>)> { ) -> Result<(i64, Option<i64>)> {
let copy = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned(); let plan = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
let Some(c) = copy else { return Ok((secs, duration)) }; let Some(p) = plan else { return Ok((secs, duration)) };
let original = |s: i64| to_original(&c.cuts, s * 1000) / 1000; let original = |s: i64| to_original(&p.cuts, s * 1000) / 1000;
let (secs, duration) = (original(secs), duration.map(original)); let (secs, duration) = (original(secs), duration.map(original));
let len = match duration { let len = match duration {
Some(d) => Some(d), Some(d) => Some(d),
@@ -235,34 +309,31 @@ pub async fn on_save(
&& len > 0 && len > 0
&& secs * 4 >= len * 3 && 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)) Ok((secs, duration))
} }
/// Makes copies until stopped: the next one anyone wants, or a wait of `IDLE` when there is /// Finds silences until stopped: in the next file anyone wants shortened, or a wait of `IDLE`
/// none. /// when there is none.
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) { pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
if !have_ffmpeg().await { 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; return;
} }
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded // 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(); let mut failed = std::collections::HashSet::new();
loop { 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`). // 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, _ = stop.changed() => return,
made = next(&ctx, &mut failed) => made, done = next(&ctx, &mut failed) => done,
}; };
match made { match done {
Ok(true) => continue, Ok(true) => continue,
Ok(false) => {} 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! { tokio::select! {
_ = stop.changed() => return, _ = stop.changed() => return,
@@ -272,28 +343,6 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
} }
} }
/// 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 { async fn have_ffmpeg() -> bool {
tokio::process::Command::new("ffmpeg") tokio::process::Command::new("ffmpeg")
.arg("-version") .arg("-version")
@@ -303,8 +352,8 @@ async fn have_ffmpeg() -> bool {
.is_ok_and(|s| s.success()) .is_ok_and(|s| s.success())
} }
/// Makes one copy someone wants, if there is one to make. A file everyone who would play the /// Plans one file someone wants shortened, if there is one. A file everyone who would play it
/// copy has already heard through gets none: finished episodes are rarely played again. /// 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 // 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. // them as passed over if a large library makes the pass slow.
async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<bool> { async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<bool> {
@@ -328,87 +377,80 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
} }
let src = crate::download::playable(Path::new(&t.path)); let src = crate::download::playable(Path::new(&t.path));
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let made = make(&src).await; let plans = match analyse(&src).await {
let (path, cuts) = match made { Ok(p) => p.unwrap_or_default(),
Ok(Some((path, cuts))) => (Some(path.display().to_string()), cuts),
Ok(None) => (None, vec![]),
Err(e) => { 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); failed.insert(t.id);
return Ok(true); return Ok(true);
} }
}; };
let saved_ms = saved(&cuts); ctx.db.store_plans(t.id, &serde_json::to_string(&plans)?).await?;
ctx.db.store_deadair(t.id, path.as_deref(), &serde_json::to_string(&cuts)?, saved_ms).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 }); 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. // The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
tracing::info!( tracing::info!(
feed = t.feed_id, feed = t.feed_id,
path = %src.display(), path = %src.display(),
cuts = cuts.len(), cuts = usual.map_or(0, |p| p.cuts.len()),
saved_s = saved_ms / 1000, saved_s = saved_ms / 1000,
took_ms = started.elapsed().as_millis() as u64, took_ms = started.elapsed().as_millis() as u64,
"DeadAir copy made" "DeadAir found the silences"
); );
return Ok(true); return Ok(true);
} }
Ok(false) Ok(false)
} }
/// Makes the copy of one file. None for a file it does not apply to: video, anything but MP3 /// A plan for each of `PAUSES`, for one file. None for a file DeadAir does not apply to: video,
/// and AAC, or one with no silence long enough to cut. /// anything but MP3 or AAC on its own (ADTS; AAC in MP4 has no byte ranges a playlist can
pub async fn make(src: &Path) -> Result<Option<(PathBuf, Vec<Cut>)>> { /// use), or one with no silence long enough to cut.
pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
let Some((first, frame)) = probe(src).await? else { return Ok(None) }; let Some((first, frame)) = probe(src).await? else { return Ok(None) };
let path = src.display().to_string();
let log = ffmpeg(&[ 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", "-", "-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
]) ])
.await .await
.context("finding the silences")?; .context("finding the silences")?;
let (frames, cuts) = plan(&parse_silences(&log), first, frame); let silences = parse_silences(&log);
if cuts.is_empty() { let frames = frame_bytes(&path).await?;
return Ok(None); let plans: BTreeMap<i64, Plan> = PAUSES
} .iter()
let drop: Vec<String> = frames.iter().map(|(a, b)| format!("between(n\\,{a}\\,{b})")).collect(); .map(|&pause| {
let dst = copy_path(src); let (drop, cuts) = plan(&silences, first, frame, pause as f64 / 2000.0);
// Written under another name and moved into place, so a copy cut short by a restart is (pause, Plan { cuts, pieces: pieces(&frames, &drop, frame) })
// never served. })
let tmp = dst.with_file_name(format!( .collect();
"{}.part.{}", Ok(plans.values().any(|p| !p.cuts.is_empty()).then_some(plans))
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)))
} }
/// Terms summed as a balanced tree, `((a+b)+(c+d))`. A plain `a+b+c+...` nests a level a term, /// Where each audio frame is in the file, as (byte offset, bytes), in order.
/// and Debian's ffmpeg 5.1 refuses an expression nested much past 100 (#174). async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
fn any(terms: &[String]) -> String { let out = run_out("ffprobe", &[
match terms { "-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path,
[] => "0".into(), ])
[one] => one.clone(), .await?;
_ => { // ffprobe writes the fields in its own order, not the order asked for: by name, then.
let (a, b) = terms.split_at(terms.len() / 2); out.lines()
format!("({}+{})", any(a), any(b)) .map(|l| {
} let field = |k: &str| {
} l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().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 /// 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<Option<(f64, f64)>> { async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
let path = src.display().to_string(); let path = src.display().to_string();
let out = run_out("ffprobe", &[ let out = run_out("ffprobe", &[
"-v", "error", "-show_entries", "stream=codec_type,codec_name:stream_disposition=attached_pic", "-v", "error", "-show_entries",
"-of", "json", &path, "format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic", "-of", "json", &path,
]) ])
.await?; .await?;
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?; let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
@@ -417,7 +459,9 @@ async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
.iter() .iter()
.any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1); .any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
let audio = streams.iter().find(|s| s["codec_type"] == "audio"); 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); return Ok(None);
} }
let out = run_out("ffprobe", &[ let out = run_out("ffprobe", &[
@@ -470,18 +514,48 @@ mod tests {
#[test] #[test]
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() { 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 frame = 1152.0 / 44100.0;
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame); let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame, 0.15);
assert_eq!(frames, vec![(43, 110)]); assert_eq!(frames, vec![(45, 108)]);
let Cut(s, e) = cuts[0]; let Cut(s, e) = cuts[0];
assert!(s >= 1100 && e <= 2900, "{cuts:?}"); assert!(s >= 1150 && e <= 2850, "{cuts:?}");
assert!(s - 1100 < 27 && 2900 - e < 27, "no more than a frame kept beyond KEEP: {cuts:?}"); assert!(s - 1150 < 27 && 2850 - e < 27, "no more than a frame kept beyond half the pause: {cuts:?}");
// AAC in MP4 starts a frame early, for the encoder's priming. // AAC starts a frame early, for the encoder's priming.
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0); let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0, 0.15);
assert_eq!(frames, vec![(49, 124)]); assert_eq!(frames, vec![(51, 122)]);
// A silence the margins use up entirely is left alone. // 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<u64> = 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] #[test]
@@ -493,7 +567,7 @@ mod tests {
assert_eq!(to_copy(&cuts, 40_000), 25_000); 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] { 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)); 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 }; let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
assert_eq!(back, expect, "{t}"); assert_eq!(back, expect, "{t}");
} }
@@ -511,36 +585,30 @@ mod tests {
} }
#[test] #[test]
fn the_frames_to_drop_nest_shallowly_and_fit_one_argument() { fn the_pause_is_the_feeds_then_its_groups_then_the_default() {
let terms: Vec<String> = (0..MAX_CUTS as u64) let w = Wanting {
.map(|i| format!("between(n\\,{}\\,{})", 1_000_000 + i * 300, 1_000_000 + i * 300 + 250)) people: HashMap::new(),
.collect(); pauses: [((1, "a".to_string()), 500), ((1, "g".to_string()), 400)].into(),
let e = any(&terms); groups: [("b".to_string(), "g".to_string())].into(),
let deepest = e.chars().scan(0i32, |d, c| { };
*d += match c { '(' => 1, ')' => -1, _ => 0 }; assert_eq!(w.pause(1, "a"), 500);
Some(*d) assert_eq!(w.pause(1, "b"), 400, "its group's");
}).max().unwrap(); assert_eq!(w.pause(1, "c"), DEFAULT_PAUSE);
assert!(deepest <= 14, "{deepest} deep"); assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours");
assert!(e.len() < 128 * 1024, "{} bytes", e.len());
assert_eq!(any(&terms[..3]), format!("({}+({}+{}))", terms[0], terms[1], terms[2]));
} }
#[test] /// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on,
fn the_copy_sits_beside_the_original() { /// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not
assert_eq!(copy_path(Path::new("/d/show/ep 1.mp3")), Path::new("/d/show/ep 1.deadair.mp3")); /// installed.
}
/// 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.
#[tokio::test] #[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 { if !have_ffmpeg().await {
eprintln!("no ffmpeg; skipped"); eprintln!("no ffmpeg; skipped");
return; return;
} }
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap(); 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}")); let src = dir.join(format!("t.{ext}"));
ffmpeg(&[ ffmpeg(&[
"-v", "error", "-y", "-f", "lavfi", "-i", "-v", "error", "-y", "-f", "lavfi", "-i",
@@ -549,40 +617,56 @@ mod tests {
]) ])
.await .await
.unwrap(); .unwrap();
let (dst, cuts) = make(&src).await.unwrap().expect("a copy"); let plans = analyse(&src).await.unwrap();
assert_eq!(cuts.len(), 4, "{ext}: {cuts:?}"); if ext == "m4a" {
// About 1.8 s from each of the four 2 s silences. assert!(plans.is_none(), "AAC in MP4 has no byte ranges to play");
let cut = saved(&cuts); continue;
assert!((7_000..7_300).contains(&cut), "{ext}: {cut}"); }
let plans = plans.expect("plans");
assert_eq!(plans.keys().copied().collect::<Vec<_>>(), 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 len = |p: &Path| {
let p = p.display().to_string(); let p = p.display().to_string();
async move { 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 .await
.unwrap() .unwrap()
.trim() .lines()
.parse::<f64>() .filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
.unwrap() .sum::<f64>()
} }
}; };
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"); assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
// Decodes without a complaint. // 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(); std::fs::remove_dir_all(&dir).unwrap();
} }
} }
/// A real episode, for trying the cut on something other than a test tone: /// 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 /// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. Each pause's
/// beside it to listen to. /// playlist is left beside it, `ep.mp3.300.m3u8`, to listen to.
#[cfg(test)] #[cfg(test)]
#[tokio::test] #[tokio::test]
#[ignore] #[ignore]
async fn real_file() { 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 t = std::time::Instant::now();
let (dst, cuts) = make(&src).await.unwrap().expect("a copy"); let plans = analyse(&src).await.unwrap().expect("plans");
eprintln!("{} cuts, {:.1} min saved, {:.1?} -> {}", cuts.len(), saved(&cuts) as f64 / 60_000.0, t.elapsed(), dst.display()); 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());
}
} }

View File

@@ -214,8 +214,6 @@ pub fn remove(path: &Path) -> std::io::Result<()> {
if path.is_dir() { if path.is_dir() {
return std::fs::remove_dir_all(path); 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) std::fs::remove_file(path)
} }

View File

@@ -355,28 +355,21 @@ pub mod settings {
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// DeadAir's copy of an enclosure: the same file with long silences shortened (issue #171). /// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
/// `cuts` is what the copy leaves out, as `[[start_ms, end_ms], ...]` of the original's time, /// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
/// which is what converts positions between the two. `path` is NULL for a file that cannot have /// AAC, nothing to cut), so that one is looked at once and not every pass.
/// one (video, or not MP3 or AAC), so it is looked at once and not every pass. pub mod deadair_plans {
pub mod deadair {
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair")] #[sea_orm(table_name = "deadair_plans")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub enclosure_id: i64, pub enclosure_id: i64,
#[sea_orm(column_type = "Text", nullable)]
pub path: Option<String>,
#[sea_orm(column_type = "Text")] #[sea_orm(column_type = "Text")]
pub cuts: String, pub plans: String,
pub saved_ms: i64, /// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
/// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy again.
pub made_at: i64, 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<i64>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -398,6 +391,8 @@ pub mod deadair_prefs {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")] #[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String, pub feed_id: String,
pub wanted: bool, pub wanted: bool,
/// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
pub pause_ms: Option<i64>,
} }
owned_by_user!(); owned_by_user!();

View File

@@ -81,7 +81,7 @@ impl Event {
// Noise in a terminal; a UI still gets them on the socket. // Noise in a terminal; a UI still gets them on the socket.
Event::FeedStart { feed } => format!("{feed}: checking"), Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), 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)"), Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
}) })
} }

View File

@@ -2489,7 +2489,7 @@ mod tests {
} }
#[tokio::test] #[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 ctx = test_ctx(config::Config::default()).await;
let db = &ctx.db; let db = &ctx.db;
let u = db.create_user("dee", None, false).await.unwrap(); let u = db.create_user("dee", None, false).await.unwrap();
@@ -2501,8 +2501,10 @@ mod tests {
] { ] {
db.sql(sql).await.unwrap(); db.sql(sql).await.unwrap();
} }
// Two minutes cut, both before the place saved. // Two minutes cut at the usual pause, both before the place saved; 40 s at the longest.
db.store_deadair(7, Some("/x/a.deadair.mp3"), "[[60000,120000],[200000,260000]]", 120_000).await.unwrap(); 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(); db.set_position(u, "f", "g", 300, None).await.unwrap();
let order = db::order_sql("published", "desc"); let order = db::order_sql("published", "desc");
let page = || { let page = || {
@@ -2518,22 +2520,28 @@ mod tests {
assert!(!off.enclosures[0].deadair_pending); assert!(!off.enclosures[0].deadair_pending);
db.set_deadair(u, "f", Some(true)).await.unwrap(); 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; let waiting = page().await;
assert!(waiting.enclosures[0].deadair_pending, "on, and the copy not made yet"); assert!(waiting.enclosures[0].deadair_pending, "on, and its silences not found yet");
db.store_deadair(7, Some("/x/a.deadair.mp3"), "[[60000,120000],[200000,260000]]", 120_000).await.unwrap(); db.store_plans(7, plans).await.unwrap();
let on = page().await; let on = page().await;
assert!(!on.enclosures[0].deadair_pending); assert!(!on.enclosures[0].deadair_pending);
assert_eq!((on.position, on.duration, on.enclosures[0].deadair), (180, Some(480), Some(120))); 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 playing = deadair::Playing::default();
let copy = db.copies(&[7]).await.unwrap().remove(0); let plan = deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(copy)); 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(); let saved = deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(480)).await.unwrap();
assert_eq!(saved, (320, Some(600))); assert_eq!(saved, (320, Some(600)));
assert_eq!(db.deadair_total(u).await.unwrap(), 0); 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 { for _ in 0..2 {
deadair::on_save(&ctx, &playing, u, "f", "g", 360, None).await.unwrap(); 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 the original again: saved as it is.
playing.lock().unwrap().clear(); playing.lock().unwrap().clear();
assert_eq!(deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(600)).await.unwrap(), (200, Some(600))); 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, and no playlist.
// Turned off again: the original's clock.
db.set_deadair(u, "f", Some(false)).await.unwrap(); db.set_deadair(u, "f", Some(false)).await.unwrap();
assert_eq!(page().await.enclosures[0].deadair, None); assert_eq!(page().await.enclosures[0].deadair, None);
} assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_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, "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(&copy.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();
} }
#[tokio::test] #[tokio::test]

View File

@@ -61,11 +61,7 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to"); tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
} }
let mut candidates = db.reap_candidates().await?; let 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;
}
if cfg.general.max_age_days > 0 { if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64; 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<u64> {
let size = std::fs::metadata(&c.path) let size = std::fs::metadata(&c.path)
.ok() .ok()
.filter(|m| m.is_file()) .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. // Missing is fine: the point is that it is gone.
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path)) if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
&& e.kind() != std::io::ErrorKind::NotFound && e.kind() != std::io::ErrorKind::NotFound
@@ -116,10 +112,6 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
Ok(size) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -69,7 +69,8 @@ pub fn router(state: WebState) -> Router {
.route("/admin", get(admin_page)) .route("/admin", get(admin_page))
.route("/admin.js", get(admin_js)) .route("/admin.js", get(admin_js))
.route("/media/{id}", get(media)) .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)) .route("/api/art", get(art))
.layer(middleware::from_fn_with_state(state.clone(), auth)) .layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer. // 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.js", get(app_js))
.route("/app.css", get(app_css)) .route("/app.css", get(app_css))
.route("/login.js", get(login_js)) .route("/login.js", get(login_js))
.route("/hls.js", get(hls_js))
.route("/inter.woff2", get(inter)) .route("/inter.woff2", get(inter))
.route_layer(middleware::from_fn(name_span)) .route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(access_log)) .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"))) 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 { fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js) ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
} }
@@ -773,8 +781,10 @@ struct FeedRow {
keywords: Vec<String>, keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed. /// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>, blocked: Vec<String>,
/// 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, 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, allow_explicit: bool,
auto_download: bool, auto_download: bool,
max_new_per_check: Option<usize>, max_new_per_check: Option<usize>,
@@ -839,8 +849,10 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
.collect(); .collect();
let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?; 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 pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?;
let deadair: std::collections::HashMap<String, bool> = let prefs = state.ctx.db.deadair_prefs(Some(user.id)).await?;
state.ctx.db.deadair_prefs(Some(user.id)).await?.into_iter().map(|(_, f, w)| (f, w)).collect(); 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 listed = state.ctx.db.feed_list(user.id, only).instrument(step("feed_list")).await?;
let mut out = Vec::with_capacity(mine.len()); let mut out = Vec::with_capacity(mine.len());
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { 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())) .or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()), .unwrap_or_else(|| feed.keywords.clone()),
blocked, 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: mine
.allow_explicit .allow_explicit
.or(up.and_then(|u| u.allow_explicit)) .or(up.and_then(|u| u.allow_explicit))
@@ -1577,10 +1598,12 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>, max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>, pinned: Option<bool>,
/// 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). /// follows its group again (#171).
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
deadair: Option<Option<bool>>, deadair: Option<Option<bool>>,
/// The pause DeadAir leaves where it cuts, in ms: one of `deadair::PAUSES` (#182).
deadair_pause: Option<i64>,
} }
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is /// 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")); 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 { if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).await?; state.ctx.db.set_deadair(user.id, &id, wanted).await?;
state.ctx.deadair.notify_one(); 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 -- // 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. // 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? { 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 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 /// DeadAir's playlist of a file (#181): the original's frames kept by this person's pause
/// version cached and asked the same address for more would stitch the two together. /// (#182), as byte ranges of `deadair.mp3` beside it. At an address of its own, so a player that
async fn media_deadair( /// had part of one version cached never stitches the two together.
async fn media_deadair(State(state): State<WebState>, Path(id): Path<i64>, 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<WebState>, State(state): State<WebState>,
Path(id): Path<i64>, Path(id): Path<i64>,
user: crate::db::User, user: crate::db::User,
@@ -1929,32 +1983,20 @@ async fn media_deadair(
serve_media(state, id, user, req, true).await 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 { let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response(); return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
}; };
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await { if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response(); 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(); return (StatusCode::NOT_FOUND, "not downloaded").into_response();
}; };
// Which version this person is playing, so the positions their player saves are read in its // The original itself: positions this person's player saves are in its clock again. A piece
// clock (`deadair::on_save`). // of the playlist leaves that as the playlist set it.
let key = (user.id, enc.feed_id.clone(), enc.guid.clone()); if !piece {
if copy { state.playing.lock().unwrap().remove(&(user.id, enc.feed_id.clone(), enc.guid.clone()));
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();
}
} }
match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await { match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(r) => r.into_response(), Ok(r) => r.into_response(),

View File

@@ -1751,7 +1751,7 @@ test('a feed\'s settings say what downloads, take words as chips, and keep what
await page.keyboard.press('Escape'); 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. // #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 { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
const entry = async () => { 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 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 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); 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(); expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.reload(); 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 expect(sw).not.toBeChecked();
await sw.locator('xpath=..').click(); await sw.locator('xpath=..').click();
await page.locator('#ssave').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: // 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.8 s off each of four 2 s silences. // "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' }); const row = page.locator('.ep', { hasText: 'Pauses Between' });
await expect(row).toContainText('s shorter', { timeout: 20_000 }); await expect(row).toContainText('s shorter', { timeout: 20_000 });
expect((await entry()).enclosures[0].deadair).toBeGreaterThan(5); expect((await entry()).enclosures[0].deadair).toBeGreaterThan(5);
await expect(row).not.toContainText('Cutting dead air'); 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 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'); await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
// Off from the player: the original, from the same place. // Off from the player: the original, from the same place.
await page.locator('#pdead').click(); await page.locator('#pdead').click();

View File

@@ -30,6 +30,11 @@ const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12); 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 /// 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. /// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) { 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. /// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) { export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name]; 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, { const js = swc.transformSync(src, {
filename: name + '.ts', filename: name + '.ts',
jsc: { jsc: {
@@ -88,6 +94,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist'); const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true }); fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle()); fs.writeFileSync(path.join(out, STYLE), buildStyle());
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
for (const name of Object.keys(PAGES)) { for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name); const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html); fs.writeFileSync(path.join(out, name), html);

View File

@@ -530,7 +530,11 @@ function settingsModal(f, newUrl?: string){
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')} <div class="ffield">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so <p class="cap">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 episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
are part of the telling.</p></div> are part of the telling.</p>
<span class="flabel" id="sdpausel">Pause it leaves</span>
<div class="tabs seg" role="radiogroup" aria-labelledby="sdpausel">${[200,300,400,500].map(ms=>
`<label><input type="radio" name="sdpause" value="${ms}"${(f.deadair_pause||300)===ms?' checked':''}>${ms/1000} s</label>`).join('')}</div>
<p class="cap">A longer pause sounds more relaxed and saves less time.</p></div>
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label> ${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div> <div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along <p class="cap">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<string, unknown>={ const patch: Record<string, unknown>={
keywords:chipWords($('#skw')), keywords:chipWords($('#skw')),
max_new_per_check:mode==='default'?null:mode==='all'?0:newest, 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')); 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 // 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. // API refuses it from anyone else. The folder and category are not this dialog's at all.

View File

@@ -3,9 +3,34 @@ const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean, 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}; 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), /// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// whose times the entry is already given in, else the file itself. /// 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`:`/media/${enc.id}`; 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<void>|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<void>((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%. // 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 // 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); 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. // Every play button is a pause button for what is playing, as the player bar's is.
if(resuming && !audio.paused){ audio.pause(); return; } if(resuming && !audio.paused){ audio.pause(); return; }
let wait: Promise<void>|null=null;
if(!resuming){ if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false; 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'); document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=player.src=mediaSrc(enc); wait=setSrc(player.src=mediaSrc(enc));
audio.currentTime=0; audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); 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. // 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; player.marked=false;
paintDead(); 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(); renderEntries();
} }
function mediaSession(e,f){ function mediaSession(e,f){
@@ -153,12 +180,12 @@ async function switchVersion(){
if(!enc||mediaSrc(enc)===player.src) return false; if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused; const was=!audio.paused;
player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position; 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}); 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; 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. // Called here, not left to the pause event: closing a player already paused fires none.
syncListening()}; syncListening()};
(function restore(){ (function restore(){