From f29d493a0edfc295a5cba991115406a83a4f6570 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 10 Oct 2026 02:51:17 +0000 Subject: [PATCH] Remove DeadAir copies unplayed for a week, and remake them on play (#179) A copy is nearly the size of its original and was kept as long as the original. Now `deadair.played_at` notes when one was last served (at most once an hour, since a player asks again at every seek), and each pass of the maker removes copies unplayed, or unmade, for 7 days. Only the file goes: the row keeps its cuts with its path cleared, which tells an expired copy apart from a file that cannot have one (no cuts) and keeps the background queue from making it again straight away. Serving the original of such an episode deletes the row, which puts it back on the queue; the copy then comes back within a pass, though the page does not switch to it mid-play (#178). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/db.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/deadair.rs | 28 ++++++++++++++++++++++++++++ src/entity.rs | 3 +++ src/main.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/web.rs | 5 +++++ 6 files changed, 125 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b786760..27e9e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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). ## [0.11.0] - 2026-10-09 diff --git a/src/db.rs b/src/db.rs index 7efada3..edcf9e3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -91,6 +91,10 @@ 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"; + orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; + } Ok(()) } @@ -2364,6 +2368,49 @@ impl Db { Ok(()) } + /// Notes that someone is playing a copy, so it is kept. At most once an hour: the player asks + /// for more of the file at every seek. + pub async fn deadair_played(&self, enclosure_id: i64) -> Result<()> { + let now = now(); + self.exec( + "UPDATE deadair SET played_at = $1 WHERE enclosure_id = $2 AND coalesce(played_at, 0) < $3", + vec![now.into(), enclosure_id.into(), (now - 3600).into()], + ) + .await?; + Ok(()) + } + + /// Copies nobody has played, or made, since `before`, as (enclosure, path). + pub async fn stale_copies(&self, before: i64) -> Result> { + self.rows( + "SELECT enclosure_id, path FROM deadair WHERE path IS NOT NULL AND coalesce(played_at, made_at) < $1", + vec![before.into()], + ) + .await? + .iter() + .map(|r| Ok((r.try_get("", "enclosure_id")?, r.try_get("", "path")?))) + .collect() + } + + /// Records a copy's file as removed, keeping its cuts: they mark it as one to make again + /// (`revive_copy`). + pub async fn drop_copy_path(&self, enclosure_id: i64) -> Result<()> { + self.exec("UPDATE deadair SET path = NULL WHERE enclosure_id = $1", vec![enclosure_id.into()]).await?; + Ok(()) + } + + /// Puts an episode whose copy was removed back on DeadAir's list, and says whether it was one. + /// A file with no copy because it cannot have one has no cuts, and stays off the list. + pub async fn revive_copy(&self, enclosure_id: i64) -> Result { + let n = self + .exec( + "DELETE FROM deadair WHERE enclosure_id = $1 AND path IS NULL AND cuts <> '[]'", + vec![enclosure_id.into()], + ) + .await?; + Ok(n > 0) + } + /// The copies there are of these enclosures, made since each was last downloaded. pub async fn copies(&self, enclosure_ids: &[i64]) -> Result> { if enclosure_ids.is_empty() { diff --git a/src/deadair.rs b/src/deadair.rs index bae23ed..439a0aa 100644 --- a/src/deadair.rs +++ b/src/deadair.rs @@ -37,6 +37,9 @@ const MAX_CUTS: usize = 3500; /// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -248,6 +251,9 @@ pub async fn run(ctx: Arc, mut stop: tokio::sync::watch::Receiver) { // as the file having no copy 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! { _ = stop.changed() => return, @@ -266,6 +272,28 @@ pub async fn run(ctx: Arc, mut stop: tokio::sync::watch::Receiver) { } } +/// Removes the copies nobody has played for `KEEP_COPY`. Their cuts stay, and playing the +/// original puts the episode back on the list (`Db::revive_copy`). +pub async fn expire(ctx: &Ctx) -> Result<()> { + for (id, path) in ctx.db.stale_copies(crate::db::now() - KEEP_COPY).await? { + // Only ever DeadAir's own file (`copy_path`): anything else would be someone's original. + if !path.contains(".deadair") { + continue; + } + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + tracing::warn!(path, error = %e, "DeadAir could not remove an old copy"); + continue; + } + } + ctx.db.drop_copy_path(id).await?; + tracing::info!(path, "DeadAir copy removed, unplayed for a week"); + } + Ok(()) +} + async fn have_ffmpeg() -> bool { tokio::process::Command::new("ffmpeg") .arg("-version") diff --git a/src/entity.rs b/src/entity.rs index 9c3c373..71a8c88 100644 --- a/src/entity.rs +++ b/src/entity.rs @@ -374,6 +374,9 @@ pub mod deadair { pub saved_ms: i64, /// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy again. pub made_at: i64, + /// When someone last played the copy, to the hour: a copy unplayed for a week is removed + /// (`deadair::expire`), its `path` cleared and its `cuts` kept. + pub played_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/main.rs b/src/main.rs index 4c8427c..2d9f0ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2547,6 +2547,47 @@ mod tests { 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(); + } + #[tokio::test] async fn two_changes_to_the_catalogue_at_once_both_keep() { let ctx = Arc::new(test_ctx(config::Config::default()).await); diff --git a/src/web.rs b/src/web.rs index 54ffe27..a15f8b2 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1948,8 +1948,13 @@ async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Reque }; path = c.path.clone(); state.playing.lock().unwrap().insert(key, Arc::new(c)); + let _ = state.ctx.db.deadair_played(id).await; } else { state.playing.lock().unwrap().remove(&key); + // A copy removed for going unplayed (#179) is made again once someone plays the episode. + if state.ctx.db.revive_copy(id).await.unwrap_or(false) { + state.ctx.deadair.notify_one(); + } } match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await { Ok(r) => r.into_response(),