Emit Podcast 2.0 chapters from ytdl-sub info.json #38
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/issue-31-podcast-chapters"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #31
Emits the Podcast 2.0 chapters extension (external JSON), scoped to chapters only — not the broader namespace.
Code
src/sub2pod/chapters.py(new) —Chapter,parse_chapters()(reads the.info.json, mapsstart_time/title),chapters_to_json()(Podcast Index JSON).discovery.py—Episode.chapters; discovers the.info.jsonsidecar.feed.py— declaresxmlns:podcast, emits<podcast:chapters url type>and writes{stem}.chapters.json.Docs
docs/ytdl-sub-integration.md— documentsoutput_options.info_json_name+ the.info.jsonsidecar, with a "Configuring episode chapters" section.README.md— chapters bullet + input-format line.AGENTS.md—.info.jsoninput-contract row.Cleanup
concept.mdandimprovements.md(superseded — all tracked work is complete; CHANGELOG + PR history is the record).Chapters are optional: no
.info.jsonsidecar (or an emptychaptersarray) → no element, no JSON file. Format verified against AntennaPod's parser.Covered by Tryke tests (red → green): parse, serialise, discovery, and feed emission + file writing.
WuMing
Found 1 issue(s). See inline comments below.
@ -55,6 +55,7 @@ Podcast 2.0 namespace (`xmlns:podcast`):- `<pubDate>` from `.nfo` `<aired>` or filename date- `<itunes:duration>` from `.nfo` `<runtime>`- `<description>` / `<itunes:summary>` from `.nfo` `<plot>`- `<podcast:chapters>` from the `.info.json` sidecar (when present)docs [LOW]
The parenthetical '(when present)' is ambiguous: it can be read as 'when the .info.json sidecar is present', but the feature only emits podcast:chapters when that sidecar contains a non-empty chapters array. Clarify, for example: 'from the
.info.jsonsidecar when it contains chapters'.concept.mdline 58✅ Fixed in commit
6b9feb8— reworded to "from the.info.jsonsidecar when it contains chapters".WuMing
Found 5 issue(s). See inline comments below.
@ -19,6 +20,10 @@ The OPML index that lists all your podcast feeds now includes a `dateModified` t---Episodes that have chapter markers now get a `chapters.json` sidecar and a `<podcast:chapters>` reference in the feed, so supporting clients can offer skip-to-chapter navigation.docs [LOW]
The implementation writes
{stem}.chapters.json, not a file literally namedchapters.json. Saying "achapters.jsonsidecar" is ambiguous; use "a.chapters.jsonsidecar" or "a{stem}.chapters.jsonsidecar" to match the actual filename pattern.@ -55,6 +55,7 @@ Podcast 2.0 namespace (`xmlns:podcast`):- `<pubDate>` from `.nfo` `<aired>` or filename date- `<itunes:duration>` from `.nfo` `<runtime>`- `<description>` / `<itunes:summary>` from `.nfo` `<plot>`- `<podcast:chapters>` from the `.info.json` sidecar when it contains chaptersdocs [LOW]
The
<podcast:chapters>element is an empty pointer (url+type) to the generated external JSON sidecar, not an inline container. The wording "from the .info.json sidecar" could be read as if chapters are embedded directly. Consider rephrasing to something like: "<podcast:chapters>referencing the generatedchapters.jsonsidecar, populated from the.info.jsonwhen it contains chapters".@ -0,0 +45,4 @@_logger.warning("Failed to parse info.json: %s", info_json_path, exc_info=True)return []chapters: list[Chapter] = []for raw in data.get("chapters", []):code [MEDIUM]
parse_chapterspromises to return[]on malformed input, but it only catchesOSErrorandjson.JSONDecodeError. If the JSON is valid but the top-level value is not a dict (for example[]or"str"), ordata.get("chapters")returns a non-list, or a chapter entry is not a dict, this line raises an unhandledAttributeError/TypeErrorand can abort discovery. Validate the JSON shape afterjson.loads(e.g.,if not isinstance(data, dict): return [], and similarly checkchaptersand eachraw).@ -0,0 +49,4 @@start_time = raw.get("start_time")title = raw.get("title")if isinstance(start_time, (int, float)) and isinstance(title, str):chapters.append(Chapter(start_time=int(start_time), title=title))code [LOW]
start_timeis explicitly allowed to be a float, butint(start_time)truncates fractional seconds. If yt-dlp ever emits fractional chapter boundaries, the Podcast Index JSON loses precision and chapters may start too early. Preserve the numeric value or round to the nearest integer instead of truncating.@ -675,0 +709,4 @@episode.chapters = [Chapter(start_time=0, title="Intro")]channel_info = ChannelInfo(title="My Show", genre=None)write_feed(channel_dir, channel_info, [episode], _BASE_URL)expect((channel_dir / "2024-03-15 - Ep Ch.chapters.json").exists()).to_be_truthy()code [LOW]
This test only asserts that the chapters JSON file exists; it does not verify the serialized content.
chapters_to_jsoncould emit wrong keys, an empty payload, or truncated start times and this test would still pass. Parse the file and assert theversionand chapter array contents.WuMing review — all 5 addressed
src/sub2pod/chapters.pyline 48✅ Fixed in commit
b632164— validatesdatais a dict,chaptersis a list, and skips non-dict entries. Added three tests (JSON-is-a-list, chapters-not-a-list, non-dict entry).src/sub2pod/chapters.pyline 52✅ Fixed in commit
b632164— now usesround()instead ofint().tests/test_feed.pyline 712✅ Fixed in commit
b632164— the test now parses the written JSON and assertsversionand the chapter array.concept.mdline 58✅ Fixed in commit
7afa833— reworded to "referencing the generated.chapters.jsonsidecar, populated from the.info.jsonwhen it contains chapters".Release-Notes.mdline 23✅ Fixed in commit
7afa833— now says "a.chapters.jsonsidecar".WuMing
Found 2 issue(s). See inline comments below.
@ -101,6 +102,7 @@ Links with Friends/├── poster.jpg ← Podcast artwork → <itunes:image>├── 2026-05-19 - Episode Title.opus ← Audio file├── 2026-05-19 - Episode Title.nfo ← Episode metadata sidecar├── 2026-05-19 - Episode Title.info.json ← Episode chapters sidecar (optional)docs [LOW]
The
.info.jsonfile is yt-dlp metadata, not the sub2pod-generated chapters sidecar (which is{stem}.chapters.json). To avoid ambiguity, call it something like 'yt-dlp metadata sidecar (source of chapter markers)' to match docs/ytdl-sub-integration.md.@ -119,0 +131,4 @@info_json_name: "{upload_date_standardized} - {title}.{info_json_ext}"```When an episode has chapters, sub2pod writes a `{stem}.chapters.json` file anddocs [MEDIUM]
The
{stem}placeholder is not defined and the file location is omitted. Clarify that the chapters JSON is named after the episode (e.g.,2026-05-19 - Episode Title.chapters.json) and where it is written relative to the feed, so readers know what file is produced and what URL is referenced.WuMing review — both addressed
docs/ytdl-sub-integration.mdline 134✅ Fixed in commit
db9c3e4— reworded to "writes a.chapters.jsonfile beside the audio, named after the episode (e.g.2026-05-19 - Episode Title.chapters.json), and references it from the feed item".README.mdline 105✅ Fixed in commit
db9c3e4— relabelled to "yt-dlp metadata sidecar (chapters source, optional)" in README, and aligned the same wording in AGENTS.md.