DeadAir on the server: copies with long silences shortened (#171)

For anyone who turns DeadAir on, a background task makes each episode a
copy with every silence quieter than -38 dB for over 0.35 s shortened to
0.2 s. Nothing is re-encoded: ffmpeg copies the original's frames and
leaves the silent ones out, about 11 s for an hour of MP3 in a release
build. On Brad & Will's episode 359 that is 520 cuts and 3.3 minutes
saved, the copy decodes clean, and it has no isolated spikes in its
silences that the original lacks.

Cuts are made from the middle of each silence, keeping 0.1 s at each
end, so the frames either side 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, so the first frame after a cut decodes against the
wrong neighbour. Inside a silence nobody hears that; at a word they would.

Positions stay in the original's time and the server converts them both
ways. The iOS and Android apps save positions themselves from their own
player, even while the page is asleep, so converting in the page would
have stored the copy's times as the original's. /media/{id}/deadair
serves the copy at its own address and records that this person is
playing it; a position saved meanwhile is converted from the copy's
clock, as is the length a player measures.

The copy is made by a task of its own, not the worker: a first pass over
a library is hours of ffmpeg, and scans must not wait behind it. It
checks every two minutes, which covers new downloads, files already
downloaded, a switch turned on and a file downloaded again with one rule.
Episodes everyone who would hear the copy has finished get none.
download::remove deletes a copy with its original, and the quota counts
copies.

Settings: the switch is PATCH /api/me {deadair}, a feed kept out is
PATCH /api/feeds/{id} {deadair: false}. GET /api/me gives the switch and
deadair_saved, the seconds saved on episodes heard 75% or more on a copy,
each counted once. Three new tables, so no ALTER.

The image gains Debian's ffmpeg, about 480 MB, in a layer that stays
cached until the base image changes. The player's switch, the feed
setting and the stat come next, after mockups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:36:49 +00:00
parent 8687e4ff1b
commit 63e192767d
10 changed files with 968 additions and 8 deletions

View File

@@ -32,6 +32,7 @@ pub struct WebState {
pub events: broadcast::Sender<Event>,
/// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>,
pub playing: crate::deadair::Playing,
}
pub fn router(state: WebState) -> Router {
@@ -67,6 +68,7 @@ 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("/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.
@@ -373,8 +375,12 @@ async fn me(
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
let prefs = state.ctx.db.deadair_prefs(Some(user.id)).await.unwrap_or_default();
let deadair = prefs.iter().any(|(_, f, on)| f.is_empty() && *on);
let saved = state.ctx.db.deadair_total(user.id).await.unwrap_or(0) / 1000;
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
"deadair": deadair, "deadair_saved": saved,
}))
}
@@ -382,6 +388,8 @@ async fn me(
struct MePatch {
/// Words that hide an item in every feed you read.
blocked: Option<Vec<String>>,
/// DeadAir's switch, beside the speed (#171).
deadair: Option<bool>,
}
/// Saves the block list for every feed. The theme is not the account's: each browser keeps its
@@ -394,6 +402,9 @@ async fn patch_me(
if let Some(words) = body.blocked {
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
}
if let Some(on) = body.deadair {
state.ctx.db.set_deadair(user.id, "", Some(on)).await?;
}
Ok(StatusCode::NO_CONTENT)
}
@@ -768,6 +779,9 @@ 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 when your switch is on: false for a feed you keep out, by
/// itself or by its group.
deadair: bool,
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option<usize>,
@@ -832,6 +846,8 @@ 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 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)) {
@@ -856,6 +872,7 @@ 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(true),
allow_explicit: mine
.allow_explicit
.or(up.and_then(|u| u.allow_explicit))
@@ -1444,6 +1461,7 @@ async fn entry_page(
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
}
}
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).await?;
let total = db.count_in(user_id, feed, filter, search).await?;
Ok(Json(EntryPage { total, entries: rows }))
}
@@ -1547,6 +1565,10 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>,
/// False keeps the feed out of DeadAir, true lets it in under a group kept out, null
/// follows the group again.
#[serde(default, deserialize_with = "double_option")]
deadair: Option<Option<bool>>,
}
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
@@ -1584,6 +1606,9 @@ async fn patch_feed(
{
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).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? {
@@ -1877,15 +1902,42 @@ async fn media(
user: crate::db::User,
req: Request,
) -> Response {
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(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
req: Request,
) -> Response {
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 {
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(path) = enc.path else {
let Some(mut 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));
} else {
state.playing.lock().unwrap().remove(&key);
}
match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(r) => r.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
@@ -1974,7 +2026,9 @@ async fn set_position(
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration).await?;
let (secs, duration) =
crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?;
state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?;
Ok(StatusCode::NO_CONTENT)
}