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

Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses - are part of the telling.

+ are part of the telling.

+ Pause it leaves +
${[200,300,400,500].map(ms=> + ``).join('')}
+

A longer pause sounds more relaxed and saves less time.

${isGroup?'':`
${chipsHTML(f.blocked||[],'sblockin')}

Whole words or phrases. Hidden from your list and not downloaded for you, along @@ -614,7 +618,8 @@ function settingsModal(f, newUrl?: string){ const patch: Record={ keywords:chipWords($('#skw')), max_new_per_check:mode==='default'?null:mode==='all'?0:newest, - auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked}; + auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked, + deadair_pause:Number($('[name=sdpause]:checked').value)}; if($('#sblock')) patch.blocked=chipWords($('#sblock')); // The address is the same for everyone: an admin's to change, for a feed that moved, and the // API refuses it from anyone else. The folder and category are not this dialog's at all. diff --git a/web/src/player.ts b/web/src/player.ts index 0cdbddc..f09ec05 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -3,9 +3,34 @@ const audio=$('#audio'); const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean, saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false}; -/// Where a file plays from: DeadAir's copy when the server says this person plays it (#171), -/// whose times the entry is already given in, else the file itself. -const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair`:`/media/${enc.id}`; +/// Where a file plays from: DeadAir's playlist when the server says this person plays it +/// shortened (#171, #181), whose times the entry is already given in, else the file itself. +const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`; + +/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it +/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails +/// on the playlist's MP3 ranges with "stream parsing failed", and so plays nothing. +let hls: any=null; +function dropHls(){ if(hls){ hls.destroy(); hls=null; } } +/// Puts a file in the player: null when it is there at once, else a promise to wait on before +/// playing, while hls.js loads. Safari always takes the first way, where play() has to run within +/// the tap itself or it is refused. +function setSrc(url: string): Promise|null{ + dropHls(); + const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl'); + if(!url.endsWith('.m3u8')||native){ audio.src=url; return null; } + const w=window as any; + const ready=w.Hls?Promise.resolve():new Promise((ok,fail)=>{ + const s=document.createElement('script'); + s.src='__HLS_JS__'; s.onload=()=>ok(); s.onerror=()=>fail(new Error('the DeadAir player did not load')); + document.head.append(s); + }); + return ready.then(()=>{ + hls=new w.Hls(); + hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal) toast('Playback failed: '+d.details,true); }); + hls.loadSource(url); hls.attachMedia(audio); + }); +} // Marked read when an item has actually been listened to -- at the end, or past 90%. // NOT on play: doing that made the item vanish from the Unread list the instant @@ -29,10 +54,11 @@ function play(e,enc=e.enclosures.find(isPlayable)){ const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc); // Every play button is a pause button for what is playing, as the player bar's is. if(resuming && !audio.paused){ audio.pause(); return; } + let wait: Promise|null=null; if(!resuming){ player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false; document.body.classList.toggle('has-video', kindOf(enc)==='video'); - audio.src=player.src=mediaSrc(enc); + wait=setSrc(player.src=mediaSrc(enc)); audio.currentTime=0; if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); // Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art. @@ -45,7 +71,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){ player.marked=false; paintDead(); } - audio.play().catch(err=>toast('Playback failed: '+err.message,true)); + const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true)); + if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go(); renderEntries(); } function mediaSession(e,f){ @@ -153,12 +180,12 @@ async function switchVersion(){ if(!enc||mediaSrc(enc)===player.src) return false; const was=!audio.paused; player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position; - audio.src=player.src; + const wait=setSrc(player.src); audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); - if(was) audio.play().catch(()=>{}); + if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{}); return true; } -$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries(); +$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries(); // Called here, not left to the pause event: closing a player already paused fires none. syncListening()}; (function restore(){