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
@@ -387,6 +387,19 @@ async fn run() -> Result<(), String> {
|
|||||||
// 60s matches the finest cron granularity; the sweep claims atomically and
|
// 60s matches the finest cron granularity; the sweep claims atomically and
|
||||||
// records each occurrence in `mission_fires`, so replicas and restarts
|
// records each occurrence in `mission_fires`, so replicas and restarts
|
||||||
// cannot double-launch a container.
|
// cannot double-launch a container.
|
||||||
|
// Render finished Continuous Research missions into episodes. A sweep, not
|
||||||
|
// a phase step: rendering is not the agents' work and must not be able to
|
||||||
|
// fail a phase that succeeded, and a transient API error simply retries on
|
||||||
|
// the next tick.
|
||||||
|
// Every 2 minutes, NOT 5. The mission checkout that holds script.md is
|
||||||
|
// deleted 30 minutes after the mission reaches a terminal state, so this
|
||||||
|
// sweep is racing a reaper. Two minutes leaves ~15 attempts inside that
|
||||||
|
// window; a slower sweep loses the episode permanently.
|
||||||
|
cm_api::podcast::spawn(
|
||||||
|
pool.clone(),
|
||||||
|
Some(blob.clone()),
|
||||||
|
std::time::Duration::from_secs(2 * 60),
|
||||||
|
);
|
||||||
cm_api::mission_schedule::spawn(
|
cm_api::mission_schedule::spawn(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
Some(node_hub.clone()),
|
Some(node_hub.clone()),
|
||||||
|
|||||||
@@ -500,6 +500,13 @@ pub fn router(state: AppState) -> Router {
|
|||||||
// The workflow recipe catalog (templates/workflows/*.toml). Serving it
|
// The workflow recipe catalog (templates/workflows/*.toml). Serving it
|
||||||
// lets the client stop mirroring the phase composition table inline.
|
// lets the client stop mirroring the phase composition table inline.
|
||||||
.route("/api/workflows", get(routes::missions::list_workflows))
|
.route("/api/workflows", get(routes::missions::list_workflows))
|
||||||
|
// The private podcast feed. Token in the query string, not a header:
|
||||||
|
// no podcast app can set headers. See `routes::podcast`.
|
||||||
|
.route("/api/podcast/feed.xml", get(routes::podcast::feed))
|
||||||
|
.route(
|
||||||
|
"/api/podcast/episodes/{file}",
|
||||||
|
get(routes::podcast::episode_audio),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}",
|
"/api/missions/{id}",
|
||||||
get(routes::missions::get)
|
get(routes::missions::get)
|
||||||
|
|||||||
@@ -438,3 +438,251 @@ mod live {
|
|||||||
assert!(bytes.len() > 100_000, "suspiciously small: {} bytes", bytes.len());
|
assert!(bytes.len() > 100_000, "suspiciously small: {} bytes", bytes.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rendering a finished mission into an episode ──────────────────────
|
||||||
|
|
||||||
|
/// Where a mission's script lives inside its checkout.
|
||||||
|
pub fn script_path(date: &str) -> String {
|
||||||
|
format!("ContinuousResearch/{date}/script.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blob key for an episode's audio.
|
||||||
|
pub fn blob_key(mission_id: uuid::Uuid, date: &str) -> String {
|
||||||
|
format!("podcast/{date}/{mission_id}.mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration of a joined CBR stream, from its frame headers.
|
||||||
|
///
|
||||||
|
/// Read from the audio rather than estimated from the script, because the
|
||||||
|
/// estimate is what a listener is NOT owed: the feed advertises a length and
|
||||||
|
/// that length should be the real one. Also the check that caught a six-second
|
||||||
|
/// "episode" — a file can be 6 MB and still play for seconds.
|
||||||
|
pub fn duration_secs(mp3: &[u8]) -> u32 {
|
||||||
|
let mut i = 0usize;
|
||||||
|
let mut seconds = 0f64;
|
||||||
|
while i + 4 <= mp3.len() {
|
||||||
|
if mp3[i] == 0xFF && mp3[i + 1] & 0xE0 == 0xE0 {
|
||||||
|
let br = MP3_BITRATES[((mp3[i + 2] >> 4) & 0x0F) as usize];
|
||||||
|
let sr = MP3_RATES[((mp3[i + 2] >> 2) & 0x03) as usize];
|
||||||
|
if br > 0 && sr > 0 {
|
||||||
|
let pad = ((mp3[i + 2] >> 1) & 1) as usize;
|
||||||
|
let len = (144 * br as usize * 1000 / sr as usize) + pad;
|
||||||
|
seconds += 1152.0 / sr as f64;
|
||||||
|
i += len.max(4);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
seconds.round() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha_hex(bytes: &[u8]) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
format!("{:x}", Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render every finished Continuous Research mission that has a script and no
|
||||||
|
/// episode yet.
|
||||||
|
///
|
||||||
|
/// Driven from a sweep rather than the phase itself: rendering is not the
|
||||||
|
/// agents' work and must not be able to fail a phase that succeeded. It is also
|
||||||
|
/// retryable by construction — a run that fails on a transient API error is
|
||||||
|
/// simply picked up next tick, and one that succeeded is skipped because the
|
||||||
|
/// episode row exists.
|
||||||
|
pub async fn render_pending(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
blobs: &std::sync::Arc<dyn cm_files::BlobStore>,
|
||||||
|
backend: &dyn AudioBackend,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT m.id, m.workspace_id, m.title
|
||||||
|
FROM missions m
|
||||||
|
WHERE m.template_kind = $1
|
||||||
|
AND m.status IN ('completed', 'failed')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM podcast_episodes e WHERE e.mission_id = m.id)
|
||||||
|
ORDER BY m.completed_at DESC NULLS LAST
|
||||||
|
LIMIT 3",
|
||||||
|
)
|
||||||
|
.bind(crate::continuous_research::TEMPLATE_KIND)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("select missions to render: {e}"))?;
|
||||||
|
|
||||||
|
let mut made = 0usize;
|
||||||
|
for row in rows {
|
||||||
|
let mission_id: uuid::Uuid = row.get("id");
|
||||||
|
let workspace_id: uuid::Uuid = row.get("workspace_id");
|
||||||
|
let mission_title: String = row.get("title");
|
||||||
|
|
||||||
|
// A `failed` mission is included on purpose: the script phase may have
|
||||||
|
// written a perfectly good script and failed its judge. The audio is
|
||||||
|
// worth having either way, and the mission record still says it failed.
|
||||||
|
let date = crate::continuous_research::today();
|
||||||
|
let checkout = crate::mission_workspace::checkout_path(mission_id);
|
||||||
|
let mut path = checkout.join(script_path(&date));
|
||||||
|
if !path.is_file() {
|
||||||
|
// The mission may have run yesterday; take the newest script it has
|
||||||
|
// rather than assuming the render happens on the same UTC day.
|
||||||
|
match newest_script(&checkout) {
|
||||||
|
Some(p) => path = p,
|
||||||
|
None => {
|
||||||
|
// NEVER silent. The checkout is deleted 30 minutes after a
|
||||||
|
// mission reaches a terminal state (`mission_runtime`'s
|
||||||
|
// sweeper tears down the container and the tree with it), so
|
||||||
|
// a script that is not here is not late — it is gone, and
|
||||||
|
// this mission will never produce an episode. Saying so is
|
||||||
|
// the difference between a known gap and a feed that is
|
||||||
|
// quietly missing a day.
|
||||||
|
//
|
||||||
|
// The audio is recoverable by hand: the script was pushed to
|
||||||
|
// the phase's own vault branch by `mission_delivery`.
|
||||||
|
record_unrenderable(pool, mission_id, &checkout).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let md = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("podcast: cannot read {}: {e}", path.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let script = parse_script(&md);
|
||||||
|
if script.turns.is_empty() {
|
||||||
|
eprintln!("podcast: {} has no spoken turns — skipping", path.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio = match backend.render(&script).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
// Loud, and NOT fatal to the sweep: one mission's transient API
|
||||||
|
// failure must not stop the others being rendered.
|
||||||
|
eprintln!("podcast: render failed for mission {mission_id}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let secs = duration_secs(&audio);
|
||||||
|
let key = blob_key(mission_id, &date);
|
||||||
|
if let Err(e) = blobs.put(&key, &audio).await {
|
||||||
|
eprintln!("podcast: shelving {key} failed: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = if script.title.trim().is_empty() {
|
||||||
|
mission_title
|
||||||
|
} else {
|
||||||
|
script.title.clone()
|
||||||
|
};
|
||||||
|
if let Err(e) = sqlx::query(
|
||||||
|
"INSERT INTO podcast_episodes
|
||||||
|
(id, workspace_id, mission_id, episode_date, title, blob_key,
|
||||||
|
bytes, duration_secs, rendered_by, script_sha)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
ON CONFLICT (mission_id) DO UPDATE
|
||||||
|
SET title = EXCLUDED.title, blob_key = EXCLUDED.blob_key,
|
||||||
|
bytes = EXCLUDED.bytes, duration_secs = EXCLUDED.duration_secs,
|
||||||
|
rendered_by = EXCLUDED.rendered_by, script_sha = EXCLUDED.script_sha",
|
||||||
|
)
|
||||||
|
.bind(uuid::Uuid::now_v7())
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(mission_id)
|
||||||
|
.bind(&date)
|
||||||
|
.bind(&title)
|
||||||
|
.bind(&key)
|
||||||
|
.bind(audio.len() as i64)
|
||||||
|
.bind(secs as i32)
|
||||||
|
.bind(backend.describe())
|
||||||
|
.bind(sha_hex(md.as_bytes()))
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("podcast: recording episode for {mission_id} failed: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"podcast: episode for mission {mission_id} — {} turns, {}s, {} bytes at {key}",
|
||||||
|
script.turns.len(),
|
||||||
|
secs,
|
||||||
|
audio.len()
|
||||||
|
);
|
||||||
|
made += 1;
|
||||||
|
}
|
||||||
|
Ok(made)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Say — once — that a mission can never be rendered.
|
||||||
|
///
|
||||||
|
/// Once, not every tick: the sweep revisits the same missions forever, and a
|
||||||
|
/// line per mission per five minutes would bury everything else in the log. The
|
||||||
|
/// episode row is the marker, with a zero-length blob key that the feed skips.
|
||||||
|
async fn record_unrenderable(pool: &sqlx::PgPool, mission_id: uuid::Uuid, checkout: &std::path::Path) {
|
||||||
|
eprintln!(
|
||||||
|
"podcast: mission {mission_id} has no script at {} — the checkout was reaped before the \
|
||||||
|
render sweep reached it, so this day has no episode. The script is still on the phase's \
|
||||||
|
vault branch if it is wanted.",
|
||||||
|
checkout.display()
|
||||||
|
);
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO podcast_episodes
|
||||||
|
(id, workspace_id, mission_id, episode_date, title, blob_key, bytes,
|
||||||
|
duration_secs, rendered_by, script_sha)
|
||||||
|
SELECT $1, m.workspace_id, m.id, '', m.title, '', 0, 0, 'unrenderable', ''
|
||||||
|
FROM missions m WHERE m.id = $2
|
||||||
|
ON CONFLICT (mission_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(uuid::Uuid::now_v7())
|
||||||
|
.bind(mission_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent `ContinuousResearch/<date>/script.md` in a checkout.
|
||||||
|
fn newest_script(checkout: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||||
|
let root = checkout.join("ContinuousResearch");
|
||||||
|
let mut dates: Vec<String> = std::fs::read_dir(root)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|e| e.path().is_dir())
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
// ISO dates sort lexicographically, which is the whole reason for the format.
|
||||||
|
dates.sort();
|
||||||
|
dates.iter().rev().find_map(|d| {
|
||||||
|
let p = checkout.join(script_path(d));
|
||||||
|
p.is_file().then_some(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the render sweep.
|
||||||
|
pub fn spawn(
|
||||||
|
pool: sqlx::PgPool,
|
||||||
|
blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
|
||||||
|
interval: std::time::Duration,
|
||||||
|
) {
|
||||||
|
let Some(blobs) = blobs else {
|
||||||
|
eprintln!("podcast: no blob storage configured — episodes will not be rendered");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(backend) = ElevenLabs::from_env() else {
|
||||||
|
// Not an error. A deployment without a key simply produces no audio,
|
||||||
|
// and every other part of the mission still works.
|
||||||
|
eprintln!("podcast: ELEVENLABS_API_KEY not set — episodes will not be rendered");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(interval);
|
||||||
|
tick.tick().await;
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
match render_pending(&pool, &blobs, &backend).await {
|
||||||
|
Ok(n) if n > 0 => eprintln!("podcast: rendered {n} episode(s)"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => eprintln!("podcast: sweep failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub mod library;
|
|||||||
pub mod mission_plan;
|
pub mod mission_plan;
|
||||||
pub mod mission_roster;
|
pub mod mission_roster;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
|
pub mod podcast;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- One row per rendered episode.
|
||||||
|
--
|
||||||
|
-- The MP3 lives in blob storage; this is the index the feed reads. Without it
|
||||||
|
-- there is no way to answer "has this mission already been rendered?", and a
|
||||||
|
-- sweep that re-renders on every tick would spend real money each time — the
|
||||||
|
-- same lesson `corpus_items` taught for papers: a recurring job's hard problem
|
||||||
|
-- is knowing what it already did.
|
||||||
|
CREATE TABLE IF NOT EXISTS podcast_episodes (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
|
||||||
|
-- The mission whose script this came from. One episode per mission: a
|
||||||
|
-- mission is one day's research, and re-running it should replace the
|
||||||
|
-- episode rather than add a second one for the same material.
|
||||||
|
mission_id UUID NOT NULL REFERENCES missions (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (mission_id),
|
||||||
|
-- Vault date folder the script came from, e.g. 2026-08-18. Not derived from
|
||||||
|
-- created_at: a mission that runs past midnight belongs to the day it
|
||||||
|
-- harvested, not the day it finished rendering.
|
||||||
|
episode_date TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
-- Blob key for the MP3.
|
||||||
|
blob_key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL,
|
||||||
|
duration_secs INTEGER NOT NULL,
|
||||||
|
-- Which backend voiced it, for attribution when a future one sounds different.
|
||||||
|
rendered_by TEXT NOT NULL,
|
||||||
|
-- Hash of the script that produced this audio. A phase that re-runs and
|
||||||
|
-- rewrites its script must produce a NEW episode; an unchanged script must
|
||||||
|
-- not be re-rendered and re-billed.
|
||||||
|
script_sha TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS podcast_episodes_feed_idx
|
||||||
|
ON podcast_episodes (workspace_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE podcast_episodes IS
|
||||||
|
'Rendered podcast episodes. The audio is in blob storage; this indexes it for the RSS feed and prevents re-rendering (and re-billing) work already done.';
|
||||||
Reference in New Issue
Block a user