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:
@@ -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
|
||||
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
205
src/db.rs
205
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::<String>("", "path")
|
||||
&& path.contains(".deadair.")
|
||||
{
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
// IF EXISTS: the healthcheck's `ipx status` may open the database at the same moment.
|
||||
orm.execute_unprepared("DROP TABLE IF EXISTS deadair").await.context("dropping DeadAir's copies")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2257,28 +2273,21 @@ pub struct DeadAirTodo {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// An enclosure's DeadAir copy: where it is, what it left out, and how much that came to.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Copy {
|
||||
pub enclosure_id: i64,
|
||||
pub path: String,
|
||||
pub cuts: Vec<crate::deadair::Cut>,
|
||||
pub saved_ms: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// DeadAir settings as (person, feed, wanted), everyone's or one person's. An empty feed is
|
||||
/// the switch beside the speed.
|
||||
pub async fn deadair_prefs(&self, user_id: Option<i64>) -> Result<Vec<(i64, String, bool)>> {
|
||||
/// DeadAir settings as (person, feed, wanted, pause in ms), everyone's or one person's. An
|
||||
/// empty feed is the switch beside the speed.
|
||||
pub async fn deadair_prefs(&self, user_id: Option<i64>) -> Result<Vec<(i64, String, bool, Option<i64>)>> {
|
||||
let mut a = Args::default();
|
||||
let only = match user_id {
|
||||
Some(u) => format!("WHERE user_id = {}", a.p(u)),
|
||||
None => String::new(),
|
||||
};
|
||||
self.rows(&format!("SELECT user_id, feed_id, wanted FROM deadair_prefs {only}"), a.0)
|
||||
self.rows(&format!("SELECT user_id, feed_id, wanted, pause_ms FROM deadair_prefs {only}"), a.0)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| Ok((r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "wanted")?)))
|
||||
.map(|r| {
|
||||
Ok((r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "wanted")?, r.try_get("", "pause_ms")?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -2301,11 +2310,22 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files on disk with no copy made since they were downloaded, oldest first.
|
||||
/// Sets the pause DeadAir leaves in a feed for someone (#182). Only beside a setting of the
|
||||
/// switch: inserting one here would turn a feed's group's DeadAir off for it.
|
||||
pub async fn set_deadair_pause(&self, user_id: i64, feed_id: &str, pause_ms: i64) -> Result<()> {
|
||||
self.exec(
|
||||
"UPDATE deadair_prefs SET pause_ms = $1 WHERE user_id = $2 AND feed_id = $3",
|
||||
vec![pause_ms.into(), user_id.into(), feed_id.into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files on disk DeadAir has not looked at since they were downloaded, oldest first.
|
||||
pub async fn deadair_todo(&self) -> Result<Vec<DeadAirTodo>> {
|
||||
self.rows(
|
||||
"SELECT e.id, e.feed_id, e.guid, e.path FROM enclosures e
|
||||
LEFT JOIN deadair d ON d.enclosure_id = e.id
|
||||
LEFT JOIN deadair_plans d ON d.enclosure_id = e.id
|
||||
WHERE e.path IS NOT NULL AND e.state = 'done'
|
||||
AND (d.enclosure_id IS NULL OR d.made_at < coalesce(e.downloaded_at, 0))
|
||||
ORDER BY e.id",
|
||||
@@ -2356,104 +2376,42 @@ impl Db {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Records the copy made of an enclosure, or with no path that it has none.
|
||||
pub async fn store_deadair(&self, enclosure_id: i64, path: Option<&str>, cuts: &str, saved_ms: i64) -> Result<()> {
|
||||
/// Records what DeadAir found in an enclosure: its plans as JSON, `{}` for none.
|
||||
pub async fn store_plans(&self, enclosure_id: i64, plans: &str) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO deadair (enclosure_id, path, cuts, saved_ms, made_at) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (enclosure_id) DO UPDATE SET path = excluded.path, cuts = excluded.cuts,
|
||||
saved_ms = excluded.saved_ms, made_at = excluded.made_at",
|
||||
vec![enclosure_id.into(), path.map(str::to_owned).into(), cuts.into(), saved_ms.into(), now().into()],
|
||||
"INSERT INTO deadair_plans (enclosure_id, plans, made_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (enclosure_id) DO UPDATE SET plans = excluded.plans, made_at = excluded.made_at",
|
||||
vec![enclosure_id.into(), plans.into(), now().into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notes that someone is playing a copy, so it is kept. At most once an hour: the player asks
|
||||
/// for more of the file at every seek.
|
||||
pub async fn deadair_played(&self, enclosure_id: i64) -> Result<()> {
|
||||
let now = now();
|
||||
self.exec(
|
||||
"UPDATE deadair SET played_at = $1 WHERE enclosure_id = $2 AND coalesce(played_at, 0) < $3",
|
||||
vec![now.into(), enclosure_id.into(), (now - 3600).into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies nobody has played, or made, since `before`, as (enclosure, path).
|
||||
pub async fn stale_copies(&self, before: i64) -> Result<Vec<(i64, String)>> {
|
||||
self.rows(
|
||||
"SELECT enclosure_id, path FROM deadair WHERE path IS NOT NULL AND coalesce(played_at, made_at) < $1",
|
||||
vec![before.into()],
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| Ok((r.try_get("", "enclosure_id")?, r.try_get("", "path")?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Records a copy's file as removed, keeping its cuts: they mark it as one to make again
|
||||
/// (`revive_copy`).
|
||||
pub async fn drop_copy_path(&self, enclosure_id: i64) -> Result<()> {
|
||||
self.exec("UPDATE deadair SET path = NULL WHERE enclosure_id = $1", vec![enclosure_id.into()]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts an episode whose copy was removed back on DeadAir's list, and says whether it was one.
|
||||
/// A file with no copy because it cannot have one has no cuts, and stays off the list.
|
||||
pub async fn revive_copy(&self, enclosure_id: i64) -> Result<bool> {
|
||||
let n = self
|
||||
.exec(
|
||||
"DELETE FROM deadair WHERE enclosure_id = $1 AND path IS NULL AND cuts <> '[]'",
|
||||
vec![enclosure_id.into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// The copies there are of these enclosures, made since each was last downloaded.
|
||||
pub async fn copies(&self, enclosure_ids: &[i64]) -> Result<Vec<Copy>> {
|
||||
if enclosure_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut a = Args::default();
|
||||
let ids: Vec<String> = enclosure_ids.iter().map(|&i| a.p(i)).collect();
|
||||
let sql = format!(
|
||||
"SELECT d.enclosure_id, d.path, d.cuts, d.saved_ms FROM deadair d
|
||||
JOIN enclosures e ON e.id = d.enclosure_id
|
||||
WHERE d.enclosure_id IN ({}) AND d.path IS NOT NULL AND e.path IS NOT NULL
|
||||
AND d.made_at >= coalesce(e.downloaded_at, 0)",
|
||||
ids.join(", ")
|
||||
);
|
||||
self.rows(&sql, a.0)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let cuts: String = r.try_get("", "cuts")?;
|
||||
Ok(Copy {
|
||||
enclosure_id: r.try_get("", "enclosure_id")?,
|
||||
path: r.try_get("", "path")?,
|
||||
cuts: serde_json::from_str(&cuts).context("reading DeadAir's cuts")?,
|
||||
saved_ms: r.try_get("", "saved_ms")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Which of these enclosures DeadAir has dealt with since each was last downloaded, copy or
|
||||
/// none: the rest on disk are still waiting for it.
|
||||
pub async fn deadair_done(&self, enclosure_ids: &[i64]) -> Result<std::collections::HashSet<i64>> {
|
||||
/// DeadAir's plans for these enclosures, by pause, for each one it has looked at since it
|
||||
/// was last downloaded and is still on disk. Empty for a file it does not apply to; missing
|
||||
/// for one it has yet to look at.
|
||||
pub async fn plans(
|
||||
&self,
|
||||
enclosure_ids: &[i64],
|
||||
) -> Result<std::collections::HashMap<i64, std::collections::BTreeMap<i64, crate::deadair::Plan>>> {
|
||||
if enclosure_ids.is_empty() {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
let mut a = Args::default();
|
||||
let ids: Vec<String> = enclosure_ids.iter().map(|&i| a.p(i)).collect();
|
||||
let sql = format!(
|
||||
"SELECT d.enclosure_id FROM deadair d JOIN enclosures e ON e.id = d.enclosure_id
|
||||
WHERE d.enclosure_id IN ({}) AND d.made_at >= coalesce(e.downloaded_at, 0)",
|
||||
"SELECT d.enclosure_id, d.plans FROM deadair_plans d JOIN enclosures e ON e.id = d.enclosure_id
|
||||
WHERE d.enclosure_id IN ({}) AND e.path IS NOT NULL AND d.made_at >= coalesce(e.downloaded_at, 0)",
|
||||
ids.join(", ")
|
||||
);
|
||||
self.rows(&sql, a.0).await?.iter().map(|r| Ok(r.try_get("", "enclosure_id")?)).collect()
|
||||
self.rows(&sql, a.0)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let plans: String = r.try_get("", "plans")?;
|
||||
Ok((r.try_get("", "enclosure_id")?, serde_json::from_str(&plans).context("reading DeadAir's plans")?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Counts an episode towards the time DeadAir has saved someone, once however often it is
|
||||
@@ -2507,13 +2465,40 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn deadair_copies_are_made_once_per_download_and_counted_once_per_episode() {
|
||||
async fn deadair_drops_its_old_copies_once() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("ipx-deadair-old-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (orig, copy) = (dir.join("a.mp3"), dir.join("a.deadair.mp3"));
|
||||
std::fs::write(&orig, "original").unwrap();
|
||||
std::fs::write(©, "copy").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::<Vec<_>>(), [1], "only a file on disk");
|
||||
|
||||
db.store_deadair(1, Some("/x/a.deadair.mp3"), "[[1000,3000]]", 2000).await.unwrap();
|
||||
db.store_plans(1, r#"{"300": {"cuts": [[1000,3000]], "pieces": [[0,10,1.0]]}}"#).await.unwrap();
|
||||
assert!(db.deadair_todo().await.unwrap().is_empty());
|
||||
let c = db.copies(&[1, 2]).await.unwrap();
|
||||
assert_eq!((c.len(), c[0].cuts.as_slice(), c[0].saved_ms), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000));
|
||||
// Downloaded again since: the copy is stale until made again.
|
||||
let p = db.plans(&[1, 2]).await.unwrap();
|
||||
assert_eq!((p.len(), p[&1][&300].cuts.as_slice(), p[&1][&300].saved_ms()), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000));
|
||||
// Downloaded again since: looked at again before it plays shortened.
|
||||
db.orm.execute_unprepared(&format!("UPDATE enclosures SET downloaded_at = {} WHERE id = 1", now() + 60)).await.unwrap();
|
||||
assert_eq!(db.deadair_todo().await.unwrap().len(), 1);
|
||||
assert!(db.copies(&[1]).await.unwrap().is_empty());
|
||||
assert!(db.plans(&[1]).await.unwrap().is_empty());
|
||||
|
||||
assert!(!db.subscribed_any(&[u], "f").await.unwrap());
|
||||
db.subscribe(u, "f").await.unwrap();
|
||||
|
||||
490
src/deadair.rs
490
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<Cut>,
|
||||
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<f64> {
|
||||
|
||||
/// 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<Cut>) {
|
||||
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<Cut>) {
|
||||
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<String, bool>, 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<i64, HashMap<String, bool>>,
|
||||
pub pauses: HashMap<(i64, String), i64>,
|
||||
pub groups: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Wanting {
|
||||
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
|
||||
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.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<std::sync::Mutex<HashMap<(i64, String, String), Arc<crate::db::Copy>>>>;
|
||||
/// 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<i64, Plan>) -> Option<Plan> {
|
||||
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<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<()> {
|
||||
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<i64, crate::db::Copy> =
|
||||
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<i64>,
|
||||
) -> Result<(i64, Option<i64>)> {
|
||||
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<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
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<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 {
|
||||
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<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 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<Option<(PathBuf, Vec<Cut>)>> {
|
||||
/// 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<Option<BTreeMap<i64, Plan>>> {
|
||||
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<String> = 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<i64, Plan> = 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<Vec<(u64, u64)>> {
|
||||
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::<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
|
||||
/// 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)>> {
|
||||
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<Option<(f64, f64)>> {
|
||||
.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<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]
|
||||
@@ -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<String> = (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::<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 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::<f64>()
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
|
||||
.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");
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
#[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<i64>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
|
||||
@@ -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)"),
|
||||
})
|
||||
}
|
||||
|
||||
73
src/main.rs
73
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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
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<u64> {
|
||||
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<u64> {
|
||||
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::*;
|
||||
|
||||
96
src/web.rs
96
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<String>,
|
||||
/// Your words that hide an item of this feed, beside your list for every feed.
|
||||
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,
|
||||
/// 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<usize>,
|
||||
@@ -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<String, bool> =
|
||||
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<Option<usize>>,
|
||||
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).
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
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
|
||||
@@ -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<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>,
|
||||
Path(id): Path<i64>,
|
||||
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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')}
|
||||
<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
|
||||
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>
|
||||
<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
|
||||
@@ -614,7 +618,8 @@ function settingsModal(f, newUrl?: string){
|
||||
const patch: Record<string, unknown>={
|
||||
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.
|
||||
|
||||
@@ -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<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%.
|
||||
// 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<void>|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(){
|
||||
|
||||
Reference in New Issue
Block a user