Podcast RSS Feed Automation
Within the Media Delivery & Publishing Automation layer, the RSS feed is the single contract every podcast client reads — Apple Podcasts, Spotify, Overcast, and the podcast-index aggregators all resolve your show through one XML document. This stage generates that document programmatically from pipeline state and validates it before it ships, so a machine-authored feed never regresses subscriber counts, mis-scrubs playback, or silently drops an episode. The SLA it protects is strict: every published feed must parse as well-formed XML, satisfy Apple’s required-tag set, and carry an <enclosure> whose declared byte length equals the real object size to the byte. Get the length wrong and clients truncate the audio or fail the seek bar; omit a required iTunes tag and Apple rejects the whole feed on its next poll.
This component runs after an episode’s media is finalized — loudness-normalized audio, packaged renditions, and generated chapters and transcripts already sit in object storage — and it is the last automated gate before the show goes public. It reads structured episode records, renders the feed, checks it, and writes it to a stable URL behind a CDN.
Prerequisites & Environment
Feed generation is deterministic and cheap, but XML namespace handling and RFC-822 date formatting are the two places sloppy code silently produces feeds that pass a browser preview yet fail Apple’s validator. Pin the toolchain and never hand-format dates.
- Python: 3.10+ (the examples use
matchstatements andemail.utils.format_datetimefrom the standard library for RFC-822 output). - Python libraries (pinned):
# lxml==5.2.1 # namespace-correct XML tree building + serialization
# feedgen==1.0.0 # optional: fast RSS 2.0 + iTunes subset scaffolding
# requests==2.32.3 # HEAD probe against object storage for byte length
# boto3==1.34.106 # atomic upload to the delivery bucket
- Feed tags come from two namespaces plus core RSS. The iTunes tags live under
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"and the podcasting 2.0 tags underxmlns:podcast="https://podcastindex.org/namespace/1.0".feedgenships the iTunes subset via itspodcastextension but does not emit podcast-namespace 2.0 tags, so this stage builds the tree withlxmldirectly for full control and usesfeedgenonly where the iTunes subset is sufficient. - Hardware: rendering a 500-episode feed is sub-second and holds the whole tree in memory (a few MB). No GPU, no heavy CPU. The only latency is the network probe against object storage — parallelize it, described under Performance Tuning.
- Environment variables the builder reads at startup:
FEED_BASE_URL=https://cdn.example.com/shows/acme # public feed + asset host
FEED_BUCKET=acme-podcast-delivery # object store bucket
FEED_ITUNES_EMAIL=owner@example.com # itunes:owner email (Apple requires)
FEED_ITUNES_IMAGE=https://cdn.example.com/art/3000.jpg # 1400–3000px square RGB
FEED_PODCAST_GUID=cb3d9...uuidv5 # stable channel-level podcast:guid
FEED_LANGUAGE=en-us # RFC 5646 language tag
The FEED_PODCAST_GUID is a UUIDv5 derived once from the feed’s URL and then frozen; it is the show’s globally stable identity across host migrations and must never change. The authoritative tag definitions are the Apple Podcasts RSS feed requirements and the podcast-namespace specification; treat both as the source of truth over any library’s defaults.
Architecture & Queue Topology
The builder is triggered by an episode-ready event — emitted once media, chapters, and transcripts are all committed to storage — and runs a strict render-then-validate cycle. It never publishes a feed it has not first re-parsed and checked, and it treats the enclosure byte length as data it probes, never as a value it trusts from upstream metadata.
The build is a pure function of episode records plus the probed object sizes: given the same inputs it produces byte-identical XML, which makes republishing safe and diffs meaningful. Because the enclosure length is probed rather than passed in, an upstream stage that re-encodes an episode to a slightly different size cannot desynchronize the feed — the next build re-reads the real size.
Step-by-Step Implementation
Each step ends with a verification command you can run before promoting the feed. The running example builds one channel with two episodes, but the same functions scale to a full back catalogue.
1. Build the channel element with required Apple and podcast-namespace metadata
Apple rejects a feed that is missing any of <title>, <description>, <language>, <itunes:category>, <itunes:explicit>, or a channel <itunes:image> whose art is a square between 1400 and 3000 pixels. Declare both extension namespaces on the root element so every prefixed tag resolves.
# lxml==5.2.1
import os
from lxml import etree
ITUNES = "http://www.itunes.com/dtds/podcast-1.0.dtd"
PODCAST = "https://podcastindex.org/namespace/1.0"
NSMAP = {"itunes": ITUNES, "podcast": PODCAST}
def build_channel() -> etree._Element:
rss = etree.Element("rss", nsmap=NSMAP, version="2.0")
ch = etree.SubElement(rss, "channel")
etree.SubElement(ch, "title").text = "Acme Engineering Podcast"
etree.SubElement(ch, "link").text = os.environ["FEED_BASE_URL"] + "/"
etree.SubElement(ch, "language").text = os.environ["FEED_LANGUAGE"]
etree.SubElement(ch, "description").text = "Deep dives into media pipelines."
# Apple-required iTunes channel tags
etree.SubElement(ch, f"{{{ITUNES}}}explicit").text = "false"
etree.SubElement(ch, f"{{{ITUNES}}}image",
href=os.environ["FEED_ITUNES_IMAGE"])
cat = etree.SubElement(ch, f"{{{ITUNES}}}category", text="Technology")
etree.SubElement(ch, f"{{{ITUNES}}}type").text = "episodic"
owner = etree.SubElement(ch, f"{{{ITUNES}}}owner")
etree.SubElement(owner, f"{{{ITUNES}}}name").text = "Acme Media"
etree.SubElement(owner, f"{{{ITUNES}}}email").text = os.environ["FEED_ITUNES_EMAIL"]
# podcast-namespace 2.0: stable show identity
guid = etree.SubElement(ch, f"{{{PODCAST}}}guid")
guid.text = os.environ["FEED_PODCAST_GUID"]
return rss
The <itunes:type> of episodic tells clients to sort newest-first; set it to serial for a season-ordered show. The <podcast:guid> is the channel-level anchor the podcast-index ecosystem uses to deduplicate your show across hosts.
Verify the tree is well-formed and both namespaces are declared:
python -c "from feed import build_channel; from lxml import etree; \
print(etree.tostring(build_channel(), pretty_print=True).decode())" | \
grep -E 'xmlns:itunes|xmlns:podcast'
2. Probe the real enclosure byte length and MIME type
The <enclosure> length attribute is a hard contract: it must equal the exact byte length of the audio object, and type must be its real MIME type (audio/mpeg for MP3, audio/mp4 for M4A/AAC). A wrong length makes clients mis-scrub or truncate downloads. Never copy the size from an upstream record — probe the object you are actually linking.
# requests==2.32.3
import requests
MIME_BY_EXT = {".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".mp4": "video/mp4"}
def probe_enclosure(url: str) -> tuple[int, str]:
"""Return (byte_length, mime_type) for the object the feed will link."""
resp = requests.head(url, allow_redirects=True, timeout=15)
resp.raise_for_status()
length = int(resp.headers["Content-Length"])
ext = "." + url.rsplit(".", 1)[-1].lower()
mime = resp.headers.get("Content-Type", MIME_BY_EXT.get(ext, ""))
# Prefer the extension mapping over a generic octet-stream from storage
if mime in ("", "application/octet-stream"):
mime = MIME_BY_EXT[ext]
if length <= 0:
raise ValueError(f"non-positive Content-Length for {url}")
return length, mime
Object stores frequently return application/octet-stream as the Content-Type, so fall back to the extension mapping rather than shipping a generic MIME that some clients refuse to play. Fix the stored object’s metadata at upload time so the HEAD probe and the feed agree.
Verify the probe against a real object:
curl -sI "$FEED_BASE_URL/episodes/ep-002.mp3" | grep -iE 'content-length|content-type'
# the length here must match the enclosure length attribute the feed emits
3. Emit each item with a stable GUID, RFC-822 pubDate, and season/episode tags
Every <item> carries a <guid> that must be permanent — clients key an episode’s played/downloaded state on it, so changing a GUID resurfaces an old episode as “new” for every subscriber. Use a stable identifier and set isPermaLink="false" unless the GUID is literally the episode’s URL. Dates must be RFC-822; format them with email.utils.format_datetime, never with an ad-hoc strftime.
# python 3.10+
from datetime import datetime, timezone
from email.utils import format_datetime
from lxml import etree
def add_item(channel: etree._Element, ep: dict) -> None:
length, mime = ep["enclosure_length"], ep["enclosure_type"]
item = etree.SubElement(channel, "item")
etree.SubElement(item, "title").text = ep["title"]
guid = etree.SubElement(item, "guid", isPermaLink="false")
guid.text = ep["guid"] # stable, never regenerated
pub = ep["published_at"].astimezone(timezone.utc)
etree.SubElement(item, "pubDate").text = format_datetime(pub) # RFC-822
etree.SubElement(item, "enclosure", url=ep["audio_url"],
length=str(length), type=mime)
etree.SubElement(item, f"{{{ITUNES}}}episode").text = str(ep["episode"])
etree.SubElement(item, f"{{{ITUNES}}}season").text = str(ep["season"])
etree.SubElement(item, f"{{{ITUNES}}}episodeType").text = ep.get("kind", "full")
etree.SubElement(item, f"{{{ITUNES}}}duration").text = str(ep["duration_s"])
# podcast-namespace mirrors for 2.0 clients
etree.SubElement(item, f"{{{PODCAST}}}season").text = str(ep["season"])
etree.SubElement(item, f"{{{PODCAST}}}episode").text = str(ep["episode"])
format_datetime emits the correct four-digit-year RFC-822 form — Wed, 09 Apr 2025 14:00:00 +0000 — that Apple and every mainstream client parse. The <itunes:episodeType> distinguishes full, trailer, and bonus; mislabeling a trailer as full pushes it into the main episode list.
Verify one item round-trips and the enclosure length is a positive integer string:
python -c "from feed import demo_feed; from lxml import etree; \
x=demo_feed(); print(x.find('.//item/enclosure').get('length'))"
4. Wire in generated transcripts and chapters
The podcast-namespace <podcast:transcript> and <podcast:chapters> tags point clients at artifacts other pipeline stages already produced. The transcript type is the artifact’s MIME (text/vtt, application/x-subrip, text/html, or application/json); add rel="captions" when the file is time-coded captions. The chapters file has the fixed type application/json+chapters.
# python 3.10+
def add_rich_metadata(item: etree._Element, ep: dict) -> None:
if ep.get("transcript_url"):
etree.SubElement(item, f"{{{PODCAST}}}transcript",
url=ep["transcript_url"],
type=ep["transcript_type"], # e.g. "text/vtt"
rel="captions")
if ep.get("chapters_url"):
etree.SubElement(item, f"{{{PODCAST}}}chapters",
url=ep["chapters_url"],
type="application/json+chapters")
The transcript URL is the caption artifact emitted by automated chapter generation and the transcription stage; the chapters file is the JSON produced there and, for embedded playback, mirrored by chapter and metadata embedding. The full mechanics of emitting the chapters tag correctly are covered in generating podcast RSS with chapter tags.
Verify the podcast-namespace tags serialized under the right prefix:
python -c "from feed import demo_feed; from lxml import etree; \
print(etree.tostring(demo_feed()).decode())" | grep -oE 'podcast:(transcript|chapters)'
5. Validate, then publish atomically
Serialize the tree, re-parse it to prove it is well-formed, assert the Apple-required tags exist, and confirm every enclosure length is a positive integer. Only a feed that passes is uploaded — and it is uploaded to a temp key then copied to the live key, so a subscriber polling mid-write never reads a half-written feed.
# lxml==5.2.1 boto3==1.34.106
import boto3
from lxml import etree
REQUIRED = ["title", "description", "language",
f"{{{ITUNES}}}explicit", f"{{{ITUNES}}}category"]
def validate(xml_bytes: bytes) -> None:
root = etree.fromstring(xml_bytes) # raises on malformed XML
ch = root.find("channel")
for tag in REQUIRED:
if ch.find(tag) is None:
raise ValueError(f"missing required channel tag: {tag}")
for enc in root.iterfind(".//item/enclosure"):
if int(enc.get("length", "0")) <= 0:
raise ValueError(f"bad enclosure length for {enc.get('url')}")
def publish(xml_bytes: bytes) -> None:
validate(xml_bytes) # never ship an unvalidated feed
s3 = boto3.client("s3")
bucket = os.environ["FEED_BUCKET"]
s3.put_object(Bucket=bucket, Key="feed.xml.tmp", Body=xml_bytes,
ContentType="application/rss+xml; charset=utf-8")
s3.copy_object(Bucket=bucket, Key="feed.xml",
CopySource={"Bucket": bucket, "Key": "feed.xml.tmp"},
ContentType="application/rss+xml; charset=utf-8",
MetadataDirective="REPLACE")
Publishing a new feed does not by itself make clients see it — the CDN edge may still serve the previous copy until its TTL expires or you purge it, which is the job of CDN distribution and cache invalidation.
Verify the published feed parses and reports the episode count you expect:
curl -s "$FEED_BASE_URL/feed.xml" | xmllint --xpath 'count(//item)' -
# should equal the number of episodes in the source records
Data Contracts
The feed is governed by a required-field contract split across the channel and each item. A build that cannot satisfy a required field halts and quarantines rather than shipping a partial feed.
| Field | Scope | Type / format | Validation rule | Example |
|---|---|---|---|---|
title |
channel + item | string | non-empty | Acme Engineering Podcast |
language |
channel | RFC 5646 tag | present, well-formed | en-us |
itunes:image@href |
channel | URL to square art | 1400–3000 px, JPEG/PNG, RGB | .../3000.jpg |
itunes:category@text |
channel | Apple category string | in Apple’s taxonomy | Technology |
itunes:explicit |
channel + item | true | false |
present on channel | false |
podcast:guid |
channel | UUIDv5 string | stable for feed’s lifetime | cb3d9... |
enclosure@url |
item | absolute URL | resolves via HTTP(S) | .../ep-002.mp3 |
enclosure@length |
item | integer (bytes) | equals real object size, > 0 | 48210331 |
enclosure@type |
item | MIME type | matches object, not octet-stream | audio/mpeg |
guid |
item | string | permanent, isPermaLink set |
acme-0002 |
pubDate |
item | RFC-822 datetime | parseable, 4-digit year | Wed, 09 Apr 2025 14:00:00 +0000 |
itunes:duration |
item | seconds or HH:MM:SS |
> 0 | 2841 |
itunes:episode |
item | integer | ≥ 1 | 2 |
podcast:transcript@type |
item | MIME type | in accepted transcript set | text/vtt |
podcast:chapters@type |
item | fixed MIME | application/json+chapters |
application/json+chapters |
The output contract is the serialized feed plus a build record: the SHA-256 of the XML bytes (so a rebuild that changed nothing skips the CDN purge), the episode count, and the probed length for every enclosure. Downstream distribution trusts this record instead of re-reading the feed.
Resilience Patterns
The builder is a pure function of episode records and probed sizes, which keeps recovery simple.
- Idempotency. Hash the serialized feed. If the new hash matches the last published hash, skip both the upload and the cache purge — nothing changed, so nothing should churn the CDN.
- Atomic publish. Always write to a temp key and copy to the live key. A crash between the two leaves the previous good feed live; a subscriber never fetches a truncated document.
- Probe failures are retryable, schema failures are not. A HEAD timeout or 5xx from object storage is transient — back off and retry. A missing required tag or a non-positive enclosure length is a build defect that will fail identically on retry, so it goes straight to quarantine for a human or an upstream fix.
- Never publish a shrinking feed by surprise. Guard against an episode-record query that returns fewer items than the live feed (a bad join, a partial DB read). Refuse to publish a feed with fewer items than the current live one unless an explicit
allow_shrinkflag is set, so a query glitch cannot unpublish a back catalogue. - Stable GUID enforcement. Persist every emitted
<guid>keyed by episode id. If a build would emit a different GUID for an episode that already shipped, halt — a regenerated GUID re-notifies every subscriber and is almost always a bug.
Observability & Debugging
Instrument the builder so a malformed feed is caught here, not by Apple’s crawler hours later.
- Key metrics (Prometheus):
feed_build_duration_seconds— histogram, labeled by episode-count bucket.feed_enclosure_probe_failures_total— counter, labeled byreason(timeout,http_5xx,bad_length).feed_validation_failures_total— counter, labeled byrule(missing_tag,nonpositive_length,malformed_xml).feed_item_count— gauge; alert if it drops between consecutive builds withoutallow_shrink.
- Structured log fields:
feed_id,feed_sha256,item_count,enclosure_probes_ok,enclosure_probes_failed,validation_rule_failed,published_bool. - Common errors and root causes:
- Apple reports “enclosure not found” or truncated playback — the declared
lengthdisagrees with the real object; re-probe and confirm the HEADContent-Lengthmatches the emitted attribute exactly. - An old episode reappears as new for all subscribers — a
<guid>changed between builds; restore the persisted GUID and never derive it from a mutable field like the title or URL. - Feed parses in a browser but Apple rejects it — a required
<itunes:*>tag is missing or the artwork is outside the 1400–3000 px range; the browser is lenient, Apple’s validator is not.
- Apple reports “enclosure not found” or truncated playback — the declared
Performance Tuning
- Parallelize the probes, serialize nothing else. The only real latency is the per-episode HEAD request. Run the probes with a
ThreadPoolExecutorsized to 16–32; the XML build itself is single-threaded and sub-second even for thousands of items. - Cache probe results by object ETag. An episode’s audio object rarely changes after publish. Cache
(length, mime)keyed by the object’s ETag so a full-catalogue rebuild only probes objects whose ETag moved. - Cap the feed length clients fetch. Apple only ingests a bounded window of items. Emit the full archive to a separate paged feed and keep the primary
feed.xmlto the most recent 300 items to keep every subscriber poll small and fast. - Set a sane CDN TTL. A 5–15 minute edge TTL balances freshness against origin load; pair it with an explicit purge on publish rather than a TTL short enough to hammer the origin.
- Stream serialization for very large archives. For a paged archive feed with tens of thousands of items, build with
lxml’s incremental writer to keep peak memory flat instead of materializing the whole tree.
Frequently Asked Questions
Why must the enclosure length equal the exact byte size?
Many clients pre-allocate the download and drive the seek bar from the declared length. If it is larger than the real object, the client waits for bytes that never arrive and the download appears to hang; if it is smaller, the client stops early and truncates the audio. Because the value is a contract about the actual object, probe it with a HEAD request against the URL you are linking rather than copying a size from an upstream record that may describe a re-encoded file.
Should I use feedgen or build the tree with lxml?
Use feedgen==1.0.0 when the iTunes subset it exposes through its podcast extension is all you need — it is faster to scaffold. But it does not emit podcast-namespace 2.0 tags like podcast:transcript, podcast:chapters, or podcast:guid, so any podcasting 2.0 feed ends up post-processed anyway. Building directly with lxml gives you one namespace-correct tree and avoids stitching two serializers together.
What format does pubDate require?
RFC-822, with a four-digit year — for example Wed, 09 Apr 2025 14:00:00 +0000. Generate it with email.utils.format_datetime from an aware UTC datetime; a hand-rolled strftime risks a locale-dependent month or weekday abbreviation that some clients silently drop, which pushes the episode to the bottom of the sort order.
How do I reference generated transcripts and chapters in the feed?
Add a podcast:transcript tag whose type is the caption artifact's MIME (text/vtt, application/x-subrip, text/html, or application/json) with rel="captions" for time-coded files, and a podcast:chapters tag with the fixed type application/json+chapters pointing at the chapters JSON. Both URLs are artifacts other pipeline stages produced; the feed only links them.
Why did all my subscribers get re-notified of old episodes?
A guid changed. Clients key an episode's read/downloaded state entirely on its GUID, so if a build regenerates GUIDs — often because they were derived from a mutable title or URL — every episode looks new. Persist each GUID keyed by episode id, set isPermaLink="false" for opaque identifiers, and make the builder halt if it would emit a different GUID for an episode that already shipped.
Related
- Media Delivery & Publishing Automation — the parent layer this feed stage belongs to.
- Generating podcast RSS with chapter tags — the focused technique for wiring chapters JSON into the feed.
- CDN distribution and cache invalidation — makes a freshly published feed visible at the edge.
- Chapter and metadata embedding — embeds the same chapters inside the audio container for offline players.
- Automated chapter generation — produces the chapters and transcript artifacts the feed links.