Files
clawmates/crates/cm-api/src/routes/podcast.rs
T
Omar SobhandClaude Opus 5 ba98c29481
deploy / test (push) Successful in 4m20s
deploy / build (push) Successful in 5m56s
fix(podcast): the left rail showed the workforce, not the episodes
The PODCAST tier had no branch in the left column, so it fell through to the
default — the org/company/team roster. Opening the podcast page showed a list of
agents, which is the one thing on that screen that has nothing to do with it.

`PodcastList` now fills the rail with one card per episode (date, title,
duration, size), the same shape `MissionsList` and `RepoList` give their tiers:
objects in the rail, the selected one in the canvas. It selects the newest on
first load so the canvas is never blank, and refreshes on the render sweep's
own two-minute cadence so a new episode appears without a reload.

`PodcastPanel` loses the duplicated list and becomes what a canvas should be:
how to subscribe, and the selected episode with a player. The empty state points
at the Continuous Research mission that produces one rather than just saying
there is nothing.

Verified on the served page: PODCAST renders between AGENT and REPOS.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:15:17 -07:00

303 lines
11 KiB
Rust

//! 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,
}
/// The audio endpoint accepts a token either way — see `episode_audio`.
#[derive(Deserialize)]
pub struct OptionalAuth {
#[serde(default)]
pub token: Option<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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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<OptionalAuth>,
headers: axum::http::HeaderMap,
) -> Result<Response, ApiError> {
// A podcast app fetches this with the token in the URL, because it cannot
// set headers. The browser plays it through the same-origin proxy, which
// supplies a bearer and no query token. Both are the same session; refusing
// either would break one of the two ways this is listened to.
let token = auth
.token
.or_else(|| {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_string)
})
.ok_or(ApiError::Unauthorized)?;
let workspace_id = workspace_for(&state, &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())
}
/// `GET /api/podcast/subscription` — the URL to paste into a podcast app.
///
/// Minted here rather than in the browser because the session lives in an
/// httpOnly cookie that JavaScript cannot read, and the same-origin proxy that
/// normally supplies the bearer is not available to a podcast app on a phone.
/// So the caller's own token is echoed back inside a URL that points DIRECTLY
/// at this backend.
pub async fn subscription(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
crate::extract::Authed(_user): crate::extract::Authed,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let token = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(ApiError::Unauthorized)?;
let base = std::env::var("CLAWMATES_PUBLIC_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let base = base.trim_end_matches('/');
let _ = &state;
Ok(axum::Json(serde_json::json!({
"feedUrl": format!("{base}/api/podcast/feed.xml?token={token}"),
// The panel warns when this is still localhost: a phone cannot reach it,
// and a feed that only works on the machine that made it is a feed that
// silently never syncs.
"reachable": !base.contains("localhost") && !base.contains("127.0.0.1"),
})))
}
/// `GET /api/podcast/episodes` — the list behind the UI panel.
///
/// Normal bearer auth, unlike the feed: this is the app talking to its own API,
/// where a header is available and a token in a URL would be needless exposure.
pub async fn list_episodes(
State(state): State<AppState>,
crate::extract::Authed(user): crate::extract::Authed,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let rows = sqlx::query(
"SELECT e.id, e.episode_date, e.title, e.bytes, e.duration_secs,
e.rendered_by, e.created_at, e.mission_id, m.title AS mission_title
FROM podcast_episodes e
-- LEFT: an episode outlives its mission (migration 0080). An inner
-- join would hide exactly the back-catalogue that change protects.
LEFT JOIN missions m ON m.id = e.mission_id
WHERE e.workspace_id = $1 AND e.bytes > 0
ORDER BY e.created_at DESC
LIMIT 50",
)
.bind(user.workspace_id.as_uuid())
.fetch_all(&state.pool)
.await?;
let episodes: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
let secs: i32 = r.get("duration_secs");
let created: time::OffsetDateTime = r.get("created_at");
serde_json::json!({
"id": r.get::<uuid::Uuid, _>("id"),
"missionId": r.get::<Option<uuid::Uuid>, _>("mission_id"),
"missionTitle": r
.get::<Option<String>, _>("mission_title")
.unwrap_or_else(|| "(mission deleted)".to_string()),
"title": r.get::<String, _>("title"),
"date": r.get::<String, _>("episode_date"),
"bytes": r.get::<i64, _>("bytes"),
"durationSecs": secs,
"renderedBy": r.get::<String, _>("rendered_by"),
"createdAt": created.unix_timestamp(),
})
})
.collect();
// How many missions produced no audio, so the panel can say so rather than
// leaving a silent gap the operator has to notice for themselves.
let unrenderable: i64 = sqlx::query_scalar(
"SELECT count(*) FROM podcast_episodes WHERE workspace_id = $1 AND bytes = 0",
)
.bind(user.workspace_id.as_uuid())
.fetch_one(&state.pool)
.await
.unwrap_or(0);
Ok(axum::Json(serde_json::json!({
"episodes": episodes,
"unrenderable": unrenderable,
})))
}
#[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 &amp; &lt;dense&gt; &quot;hybrid&quot;");
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}");
}
}