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 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 02:51:17 +00:00
parent 9ea2a9250c
commit f29d493a0e
6 changed files with 125 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177). - 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 ## [0.11.0] - 2026-10-09

View File

@@ -91,6 +91,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; 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(()) Ok(())
} }
@@ -2364,6 +2368,49 @@ impl Db {
Ok(()) 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. /// The copies there are of these enclosures, made since each was last downloaded.
pub async fn copies(&self, enclosure_ids: &[i64]) -> Result<Vec<Copy>> { pub async fn copies(&self, enclosure_ids: &[i64]) -> Result<Vec<Copy>> {
if enclosure_ids.is_empty() { if enclosure_ids.is_empty() {

View File

@@ -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 /// 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. /// picked up this long after, at most.
const IDLE: std::time::Duration = std::time::Duration::from_secs(120); 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 the copy leaves out.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -248,6 +251,9 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
// as the file having no copy was never tried again, even once a fix was deployed (#174). // as the file having no copy was never tried again, even once a fix was deployed (#174).
let mut failed = std::collections::HashSet::new(); let mut failed = std::collections::HashSet::new();
loop { 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`). // Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
let made = tokio::select! { let made = tokio::select! {
_ = stop.changed() => return, _ = stop.changed() => return,
@@ -266,6 +272,28 @@ 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 { async fn have_ffmpeg() -> bool {
tokio::process::Command::new("ffmpeg") tokio::process::Command::new("ffmpeg")
.arg("-version") .arg("-version")

View File

@@ -374,6 +374,9 @@ pub mod deadair {
pub saved_ms: i64, pub saved_ms: i64,
/// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy again. /// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy again.
pub made_at: i64, 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)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -2547,6 +2547,47 @@ mod tests {
assert_eq!(page().await.enclosures[0].deadair, None); 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, "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(&copy.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] #[tokio::test]
async fn two_changes_to_the_catalogue_at_once_both_keep() { async fn two_changes_to_the_catalogue_at_once_both_keep() {
let ctx = Arc::new(test_ctx(config::Config::default()).await); let ctx = Arc::new(test_ctx(config::Config::default()).await);

View File

@@ -1948,8 +1948,13 @@ async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Reque
}; };
path = c.path.clone(); path = c.path.clone();
state.playing.lock().unwrap().insert(key, Arc::new(c)); state.playing.lock().unwrap().insert(key, Arc::new(c));
let _ = state.ctx.db.deadair_played(id).await;
} else { } else {
state.playing.lock().unwrap().remove(&key); 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 { match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(r) => r.into_response(), Ok(r) => r.into_response(),