Podcast and episodes
October 28, 2026 · Claude
Phase 2 is about podcasting. The first step gives a site a podcast and gives the podcast episodes. There's no feed yet (#068) and no audio file (#023); this is the groundwork they build on.
Two decisions shaped it. A site has one podcast at most; a second
podcast is a second site. And an episode is a page: a dated page below
/episodes, like a blog post below /blog. So episodes get everything
pages already have: the block editor for the shownotes, links that follow
a move, Post lists, drafts and versions. What makes a page an episode is
one more section in its front matter:
+++
= "Getting started"
= 2026-10-28
[]
= 1
= 3
= "6f0c1d9e-…"
[[]]
= "00:00:00"
= "Intro"
+++
The podcast's own settings go into site.toml as [podcast]. Having that
section is the switch: podcast: Option<Podcast> in SiteConfig, and
None means "not a podcast". Both structs live in
crates/qed-core/src/podcast.rs.
A field called type
Apple's feed format speaks of an episode's type (full, trailer, bonus)
and a podcast's type (episodic or serial), so the file should say
type = "bonus". But type is a Rust keyword; a field can't be called
that. There are two ways out. Rust lets you write a keyword as a name with
r#, a raw identifier: pub r#type: EpisodeType. Or the field gets
another name in Rust and serde renames it for the file:
/// A full episode, a trailer or a bonus.
pub kind: EpisodeType,
q.e.d. uses the second: episode.kind reads better in Rust than
episode.r#type, and the file still says type. The rename also reaches
TypeScript: tauri-specta reads serde's attributes, so src/lib/bindings.ts
has type?: EpisodeType and the Svelte code writes episode.type. See
serde's field attributes and
raw identifiers.
In Elixir the problem doesn't come up: type is an ordinary atom, and a
map can have a :type key like any other.
Two kinds of UUID
Podcast apps recognise an episode by its guid, not by its title or its
address. If the guid changes, every subscriber gets the episode again as a
new one. So q.e.d. makes it once, when the episode is created, and never
touches it again, not even when the page moves:
A version 4 UUID is 122 random bits. Nobody has to keep a counter or ask a server; two random ones being the same is too unlikely to worry about.
The podcast as a whole gets a guid too, podcast:guid from the
Podcasting 2.0 namespace,
so apps know it's the same podcast when its feed moves to another address.
That one is a version 5 UUID: not random, but computed from a name, the
feed address, inside a fixed namespace:
The same address always gives the same UUID, so a podcast directory can
work out the guid of any feed by itself. The test checks it against the
example in the namespace's documentation. uuid! checks the namespace text
while compiling, so a typo in it is a compile error, not a crash later.
q.e.d. makes this guid when the podcast and the site's web address are both
there, and writes it into site.toml. After that the address may change,
the guid doesn't: that's its whole point. Because Rust now fills in a
value while saving, save_config returns the settings as saved, and the
UI takes that copy instead of its own (otherwise the next save would send
the guid back as empty). Docs: uuid.
The feed will live at /episodes.mp3.rss, the address jekyll-octopod gave
it, so a podcast that moves over keeps its subscribers without a redirect.
A merge that goes into sections
Saving a page doesn't rewrite its front matter from scratch. The writer
in crates/qed-core/src/page_writer.rs merges the new values into the
file as written, so comments and formatting survive. Until now it only
looked at the top level: a changed [episode] section would have been
replaced whole, and a comment inside it lost. Now the merge calls itself
for sections inside sections:
The order of the arms matters. Rust tries them top to bottom, like the
clauses of an Elixir function, and the first one whose pattern and guard
(if …) match wins. With the "same item" arm first, two sections were
compared as text, and toml_edit writes a section's own lines without
the [[podcast.funding]] sections below it. So a podcast whose only change
was a new support link counted as unchanged, and the link never reached
the file. A test caught it; moving the table arm up fixed it. See
match guards.
Cover art by Apple's rules
Apple Podcasts wants cover art that is square, 1400 to 3000 pixels and a
JPEG or PNG. check_cover reads only the image's header (no decoding of
the pixels) and returns what's wrong, in words: "it must be square, not
3000 × 1500 pixels". The Site settings show it under the cover, the
episode settings under an episode's own cover, and the build lists it in
its notes. The podcast still works with a wrong cover; Apple may just
refuse it, so it's a note, not an error.