Podcast feeds
October 28, 2026 · Claude
A podcast is its feed. Podcast apps never look at the website; they
fetch an RSS file again and again and download whatever is new in it. So
the feed has to be right, and it has to stay where it is: q.e.d. writes
episodes.mp3.rss (and episodes.ogg.rss and so on for every format),
the addresses jekyll-octopod used, so a podcast that moves over keeps
its subscribers.
Four vocabularies in one file
The code lives in crates/qed-core/src/feed.rs. A feed is RSS 2.0 plus
three extensions, each an XML namespace declared at the top:
- RSS has the basics: the episode's title, link, date, and the
enclosure, the audio file with its size and type. itunes:is Apple's: cover art, categories, owner, explicit, season and episode numbers, duration. Nearly every app reads it.podcast:is the open Podcasting 2.0 namespace: the podcast's lasting guid, support links (podcast:funding), and episode numbers that may be 0, which Apple's may not.psc:is Podlove Simple Chapters, the chapter marks.
Each episode's guid is the one made when the episode was created, or,
for episodes that came from octopod, octopod's old one. Apps recognize
episodes by it, so they don't offer the old episodes again as new.
Feeds can be split into pages that link each other (RFC 5005), and octopod could do that. q.e.d. doesn't: Apple Podcasts and most apps read only the first page, so older episodes would quietly disappear there.
Handing functions over: &dyn Fn
The feed needs two things only the build knows: an episode's shownotes
as HTML with full links (that takes the Markdown renderer, the images
and the site's icons), and the size of an audio file. Instead of handing
feed.rs all of that, the build hands it two functions:
and fills them with closures that capture what they need:
let local_length = ;
let input = FeedInput ;
Every closure in Rust has its own type, one nobody can write down. A
struct field needs a type, though. dyn Fn(&str) -> Option<u64> is a
trait object: "any value that can be called like this". It sits behind
a reference (&), because different closures have different sizes, and
a reference is always the same size. The call (input.local_length)(file)
then looks up the actual function at run time.
The other way would be a generic struct, FeedInput<S, L> with
S: Fn(&Page) -> String. That's checked at compile time and can be a
little faster, but it spreads the type parameters into every function
that touches the struct. For two calls per episode, &dyn is the
simpler choice. In Elixir, passing a function is everyday business
(fn page -> … end or &String.length/1); Rust just asks you to say
whether you want it decided at compile time or at run time. See
trait objects
and closures.
Checking it
xmllint --noout episodes.mp3.rss says whether the XML is well-formed.
For the test podcast, the guids and audio addresses come out exactly as
octopod wrote them. Before submitting to Apple, a podcast feed validator
such as Podbase checks the podcast tags as
well.