Generating Podcast RSS with Chapter Tags

This page sits beneath Podcast RSS Feed Automation in the broader Media Delivery & Publishing Automation layer, and it solves one concrete task: taking the chapter markers your pipeline already generated and exposing them to podcast clients through the feed, so listeners see a navigable chapter list and per-chapter artwork instead of a flat timeline.

Problem Framing

Chapter markers exist in your pipeline as structured data — a list of start times, titles, and optional images produced upstream — but a podcast client cannot see any of it until the feed advertises it. There are two delivery mechanisms and they are not interchangeable. The podcast-namespace <podcast:chapters> tag points at an external JSON file and is what Apple Podcasts, Podcast Addict, and podcast-index players read. The older Podlove Simple Chapters (PSC) format is inlined directly into the <item> as XML and is what a wide range of legacy players still parse. Ship neither and every chapter is invisible; ship the JSON with the wrong Content-Type and clients silently skip it even though the tag is present.

The failure is quiet — nothing errors, chapters just do not appear — so it surfaces as support tickets rather than pipeline alarms:

INFO  feed.publish  ep=142 chapters_tag=emitted url=.../ep-142/chapters.json
WARN  chapters.fetch  ep=142 status=200 content_type=application/octet-stream
WARN  chapters.render  ep=142 client=apple result=ignored reason="unexpected content-type"

The tag was emitted and the file returns 200, but the object store served it as application/octet-stream, so the client refused to parse it as chapters. The fix is to serialize the markers to the exact JSON schema the spec defines, serve that file with a JSON content-type, emit the tag with the fixed application/json+chapters type attribute, and validate the whole chain before the feed goes live.

Solution Architecture

The chapter markers come from mapping transcript timestamps to chapter markers, which turns transcript timing into ordered (start, title) pairs. From there the flow forks: one branch writes a chapters JSON file to object storage and references it with <podcast:chapters>; the other inlines the same markers as PSC inside the feed item for legacy clients. A content-type check on the JSON URL gates the whole thing.

Two delivery paths for podcast chapter markers in an RSS feed A data-flow diagram. Ordered chapter markers, each a start time, title, and optional image, fork into two delivery paths. The external path serializes the markers to a chapters JSON file written to object storage and served with a JSON content-type, referenced by a podcast-namespace chapters tag with type application/json+chapters. The embedded path inlines the same markers as Podlove Simple Chapters XML directly inside the feed item. Both paths converge on the published feed item, and a content-type check validates the JSON URL before publishing. Chapter markers startTime · title · img chapters.json in object store served as application/json Inline PSC chapters psc:chapter start="HH:MM:SS" Published feed item podcast:chapters + psc external file embedded content-type checked

Emitting both paths costs almost nothing and maximizes client coverage: modern apps read the JSON, older ones read the inline PSC, and neither conflicts with the other. The JSON path also carries per-chapter artwork and links that PSC cannot express, so it is the primary target.

Implementation

1. Serialize markers to a podcast-namespace chapters JSON file

The chapters JSON schema is fixed by the spec: a top-level version and a chapters array, where each entry requires a numeric startTime in seconds (a float, not a timestamp string) and may carry title, img, url, and a toc flag to hide a chapter from the table of contents. Write the markers to that shape exactly — clients reject unknown top-level structures.

# python 3.10+   (stdlib json; markers come from the chapter-generation stage)
import json

def build_chapters_json(markers: list[dict], version: str = "1.2.0") -> str:
    """Serialize ordered chapter markers to podcast-namespace chapters JSON.

    markers: [{"start_s": float, "title": str, "img": str | None,
               "url": str | None, "toc": bool}]  sorted by start_s ascending.
    """
    chapters = []
    for m in markers:
        chapter = {"startTime": round(float(m["start_s"]), 3),
                   "title": m["title"]}
        if m.get("img"):
            chapter["img"] = m["img"]      # per-chapter artwork (JSON path only)
        if m.get("url"):
            chapter["url"] = m["url"]      # link shown for the active chapter
        if m.get("toc") is False:
            chapter["toc"] = False         # omit from the visible chapter list
        chapters.append(chapter)
    # startTime must be monotonically non-decreasing; the first chapter starts at 0
    if chapters and chapters[0]["startTime"] != 0:
        chapters.insert(0, {"startTime": 0, "title": markers[0].get("intro", "Start")})
    return json.dumps({"version": version, "chapters": chapters},
                      ensure_ascii=False)

The first chapter should begin at startTime 0 so a client always has a chapter covering the opening; if the generation stage did not emit one, synthesize it. Keep startTime as seconds with millisecond precision — passing an HH:MM:SS string here is the single most common reason the JSON parses but renders empty.

2. Upload the JSON, then emit the tags

Write the JSON to object storage with an explicit application/json content-type, then add <podcast:chapters> to the item with the fixed application/json+chapters type. For legacy coverage, also inline PSC — its start attribute is a normal-play-time timestamp (HH:MM:SS.mmm), the opposite convention from the JSON file.

# lxml==5.2.1   boto3==1.34.106
import boto3
from datetime import timedelta
from lxml import etree

PODCAST = "https://podcastindex.org/namespace/1.0"
PSC = "http://podlove.org/simple-chapters"

def upload_chapters_json(bucket: str, key: str, body: str) -> str:
    s3 = boto3.client("s3")
    s3.put_object(Bucket=bucket, Key=key, Body=body.encode("utf-8"),
                  ContentType="application/json")   # NOT octet-stream
    return f"https://{bucket}.cdn.example.com/{key}"

def _psc_timestamp(start_s: float) -> str:
    td = timedelta(seconds=start_s)
    h, rem = divmod(int(td.total_seconds()), 3600)
    m, s = divmod(rem, 60)
    ms = int((start_s - int(start_s)) * 1000)
    return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"

def attach_chapters(item: etree._Element, chapters_url: str,
                    markers: list[dict]) -> None:
    # External JSON path (primary)
    etree.SubElement(item, f"{{{PODCAST}}}chapters",
                     url=chapters_url,
                     type="application/json+chapters")
    # Inline PSC path (legacy clients)
    psc = etree.SubElement(item, f"{{{PSC}}}chapters", version="1.2")
    for m in markers:
        etree.SubElement(psc, f"{{{PSC}}}chapter",
                         start=_psc_timestamp(m["start_s"]),
                         title=m["title"])

Declare the PSC namespace on the feed’s root element (xmlns:psc="http://podlove.org/simple-chapters") alongside the podcast and itunes namespaces so the inline <psc:chapter> elements serialize under the right prefix. The external JSON tag needs no per-item namespace work beyond the root podcast declaration already present in the feed builder.

Verification

Confirm the JSON file, its content-type, and both tags before you publish. The content-type check is the one that catches the silent-skip failure.

# 1. The chapters file must return a JSON content-type, not octet-stream.
curl -sI "https://acme.cdn.example.com/ep-142/chapters.json" | grep -i content-type
# expected: content-type: application/json

# 2. The JSON must parse and every startTime must be numeric and ascending.
curl -s "https://acme.cdn.example.com/ep-142/chapters.json" | \
  python -c "import sys,json; d=json.load(sys.stdin); \
t=[c['startTime'] for c in d['chapters']]; \
assert t==sorted(t) and all(isinstance(x,(int,float)) for x in t), 'bad startTimes'; \
print(f\"{len(t)} chapters, first={t[0]}\")"
# expected: e.g. '7 chapters, first=0'

# 3. The item must carry both the podcast:chapters tag and inline PSC.
curl -s "$FEED_BASE_URL/feed.xml" | \
  xmllint --xpath "count(//*[local-name()='chapters'])" -
# expected: >= 2 per item (one podcast:chapters + one psc:chapters)

A healthy result is a chapters.json served as application/json, a monotonic startTime list beginning at 0, and both tags present in the item. If step 1 shows application/octet-stream, fix the upload metadata — the tag is worthless while the file serves the wrong type.

Failure Modes & Edge Cases

Edge case Symptom Remediation
JSON served as application/octet-stream Tag present, no chapters render Set ContentType="application/json" on upload; re-check with a HEAD request
startTime written as "00:02:00" string JSON parses, chapter list empty Emit numeric seconds (120.0) in the JSON; timestamps belong only in PSC
First chapter starts after 0 Opening minutes have no active chapter Synthesize a startTime: 0 chapter at serialization time
Non-monotonic startTime Client shows chapters out of order or drops some Sort markers ascending before serializing; reject overlaps upstream
podcast:chapters type wrong Some clients ignore the file Use the exact fixed type application/json+chapters, not application/json
PSC start given as seconds Legacy players mis-seek or skip chapters Format PSC start as HH:MM:SS.mmm, distinct from the JSON convention
Chapters JSON on a different host than the feed CORS-strict clients refuse to fetch Serve the JSON from the same CDN origin as the feed and audio
Per-chapter img under 1400px Artwork ignored or shown blurry Reference a square image ≥ 1400px, same as the show art requirement

FAQ

Do I need both podcast:chapters and inline PSC?

No, but emitting both maximizes client coverage for almost no cost. Modern apps read the external JSON referenced by podcast:chapters and get per-chapter artwork and links; many older players only understand inline Podlove Simple Chapters. Since both derive from the same marker list, generate them together and let each client pick the one it supports.

Why do my chapters not appear even though the tag is in the feed?

Almost always the chapters JSON is served with the wrong content-type. Object stores default new objects to application/octet-stream, and clients refuse to parse that as chapters even when the file is valid JSON and returns 200. Set the upload content-type to application/json and confirm it with a HEAD request; the podcast:chapters tag itself separately uses the fixed application/json+chapters type attribute.

What time format does each chapter mechanism expect?

They differ deliberately. The external JSON file's startTime is a number of seconds as a float — 120.5 — while inline PSC's start attribute is a normal-play-time timestamp string, 00:02:00.500. Mixing them up is the most common chapter bug: a timestamp string in the JSON renders an empty list, and raw seconds in PSC make legacy players mis-seek.