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(),