feat(podcast): render finished missions into episodes, and serve them as a feed
The renderer existed but nothing called it. This wires it to the missions and puts the result somewhere a phone can reach. **A sweep, not a phase step.** Rendering is not the agents' work and must not be able to fail a phase that succeeded; a transient API error simply retries next tick, and a mission already rendered is skipped because its episode row exists. `podcast_episodes` is that record — without it the sweep would re-render on every pass and re-bill for it, the same lesson `corpus_items` taught for papers. **It is racing a reaper.** script.md lives in the mission checkout, and `mission_runtime`'s sweeper deletes that tree 30 minutes after the mission reaches a terminal state. So the sweep runs every 2 minutes, leaving ~15 attempts inside the window. When it does lose — as it did for three missions that had completed hours before this shipped — it now SAYS so and records a marker rather than skipping in silence, which is how a feed ends up quietly missing a day. The feed filters those markers out: a zero-byte enclosure shows a broken episode in a podcast app, where showing nothing is honest. **Duration is read from the audio, not estimated from the script.** The feed advertises a length and that length should be the real one — and it is the check that catches a 6 MB file playing for six seconds. **The feed authenticates by query-string token**, because no podcast app can set headers. That is a real trade: the token lands in the app's database and any proxy log. It reuses `AuthService::authenticate`, so revoking the session revokes the feed with it rather than creating a second secret to forget to rotate. Titles are XML-escaped — one raw ampersand makes a client reject the WHOLE feed, not one episode. 363 tests pass. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
55b16f25c8
commit
1f39f642a3
@@ -0,0 +1,189 @@
|
||||
//! The private podcast feed.
|
||||
//!
|
||||
//! A podcast app is the right client for this: it downloads overnight, plays
|
||||
//! offline, remembers position, and has lock-screen controls — none of which a
|
||||
//! file in a folder gives you at the gym.
|
||||
//!
|
||||
//! Auth is a token in the query string, not a bearer header, because no podcast
|
||||
//! app lets you set headers. That is a real trade: the token is in the URL and
|
||||
//! therefore in the app's database and any proxy log it passes. It is scoped to
|
||||
//! reading this feed and nothing else, and can be rotated by reissuing it.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{ApiError, AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FeedAuth {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Resolve a feed token to the workspace it may read.
|
||||
///
|
||||
/// Reuses the normal API token table, so revoking a token revokes the feed with
|
||||
/// it — a second secret store for podcasts would be one more thing to forget to
|
||||
/// rotate.
|
||||
async fn workspace_for(state: &AppState, token: &str) -> Result<uuid::Uuid, ApiError> {
|
||||
let user = state
|
||||
.auth
|
||||
.authenticate(token)
|
||||
.await
|
||||
.map_err(|_| ApiError::Unauthorized)?;
|
||||
Ok(user.workspace_id.as_uuid())
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn rfc2822(ts: time::OffsetDateTime) -> String {
|
||||
// Podcast clients are strict about pubDate. `time`'s RFC2822 is exactly it.
|
||||
ts.format(&time::format_description::well_known::Rfc2822)
|
||||
.unwrap_or_else(|_| "Thu, 01 Jan 1970 00:00:00 +0000".into())
|
||||
}
|
||||
|
||||
/// `GET /api/podcast/feed.xml?token=…`
|
||||
pub async fn feed(
|
||||
State(state): State<AppState>,
|
||||
Query(auth): Query<FeedAuth>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let workspace_id = workspace_for(&state, &auth.token).await?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, episode_date, title, bytes, duration_secs, created_at
|
||||
FROM podcast_episodes
|
||||
WHERE workspace_id = $1
|
||||
-- Skip markers for missions whose script was reaped before the
|
||||
-- render sweep reached them: a zero-byte enclosure makes a podcast
|
||||
-- app show a broken episode rather than simply not showing one.
|
||||
AND bytes > 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let base = std::env::var("CLAWMATES_PUBLIC_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
let base = base.trim_end_matches('/');
|
||||
|
||||
let mut items = String::new();
|
||||
for r in &rows {
|
||||
let id: uuid::Uuid = r.get("id");
|
||||
let title: String = r.get("title");
|
||||
let date: String = r.get("episode_date");
|
||||
let bytes: i64 = r.get("bytes");
|
||||
let secs: i32 = r.get("duration_secs");
|
||||
let created: time::OffsetDateTime = r.get("created_at");
|
||||
// The token rides on the enclosure too: the app fetches the audio in a
|
||||
// separate request that carries none of the feed's context.
|
||||
let url = format!("{base}/api/podcast/episodes/{id}.mp3?token={}", auth.token);
|
||||
items.push_str(&format!(
|
||||
r#" <item>
|
||||
<title>{t}</title>
|
||||
<description>Research digest for {d}</description>
|
||||
<pubDate>{p}</pubDate>
|
||||
<guid isPermaLink="false">{id}</guid>
|
||||
<enclosure url="{u}" length="{len}" type="audio/mpeg"/>
|
||||
<itunes:duration>{secs}</itunes:duration>
|
||||
</item>
|
||||
"#,
|
||||
t = xml_escape(&title),
|
||||
d = xml_escape(&date),
|
||||
p = rfc2822(created),
|
||||
u = xml_escape(&url),
|
||||
len = bytes,
|
||||
));
|
||||
}
|
||||
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel>
|
||||
<title>ClawMates Research</title>
|
||||
<link>{base}</link>
|
||||
<description>Papers read against your projects, every morning.</description>
|
||||
<language>en-us</language>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
{items} </channel>
|
||||
</rss>
|
||||
"#
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")],
|
||||
xml,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// `GET /api/podcast/episodes/{id}.mp3?token=…`
|
||||
pub async fn episode_audio(
|
||||
State(state): State<AppState>,
|
||||
Path(file): Path<String>,
|
||||
Query(auth): Query<FeedAuth>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let workspace_id = workspace_for(&state, &auth.token).await?;
|
||||
let id = file
|
||||
.strip_suffix(".mp3")
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT blob_key, bytes FROM podcast_episodes WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let key: String = row.get("blob_key");
|
||||
let blobs = state.blobs.clone().ok_or(ApiError::Internal)?;
|
||||
let bytes = blobs.get(&key).await.map_err(|e| {
|
||||
eprintln!("podcast: reading {key}: {e}");
|
||||
ApiError::Internal
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "audio/mpeg".to_string()),
|
||||
(header::CONTENT_LENGTH, bytes.len().to_string()),
|
||||
// Podcast apps re-fetch on every refresh otherwise.
|
||||
(header::CACHE_CONTROL, "private, max-age=86400".to_string()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A title with an ampersand must not produce invalid XML — a single bad
|
||||
/// character makes a podcast app reject the WHOLE feed, not one episode.
|
||||
#[test]
|
||||
fn titles_are_xml_escaped() {
|
||||
let out = xml_escape(r#"BM25 & <dense> "hybrid""#);
|
||||
assert_eq!(out, "BM25 & <dense> "hybrid"");
|
||||
assert!(!out.contains(" & "), "raw ampersand breaks the feed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubdate_is_rfc2822() {
|
||||
let t = time::OffsetDateTime::from_unix_timestamp(1_755_000_000).unwrap();
|
||||
let s = rfc2822(t);
|
||||
// "Mon, 12 Aug 2025 ..." — clients parse this strictly.
|
||||
assert!(s.contains(", "), "{s}");
|
||||
assert!(s.ends_with("+0000"), "{s}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user