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:
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]
|
||||
|
||||
Reference in New Issue
Block a user