Emit Podcast 2.0 chapters from ytdl-sub info.json #38

Manually merged
marvin8 merged 7 commits from feat/issue-31-podcast-chapters into main 2026-08-20 09:45:02 +00:00
Collaborator

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, maps start_time/title), chapters_to_json() (Podcast Index JSON).
  • discovery.py — Episode.chapters; discovers the .info.json sidecar.
  • feed.py — declares xmlns:podcast, emits <podcast:chapters url type> and writes {stem}.chapters.json.

Docs

  • docs/ytdl-sub-integration.md — documents output_options.info_json_name + the .info.json sidecar, with a "Configuring episode chapters" section.
  • README.md — chapters bullet + input-format line.
  • AGENTS.md — .info.json input-contract row.

Cleanup

  • Deleted concept.md and improvements.md (superseded — all tracked work is complete; CHANGELOG + PR history is the record).

Chapters are optional: no .info.json sidecar (or an empty chapters array) → 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.

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`, maps `start_time`/`title`), `chapters_to_json()` (Podcast Index JSON). - `discovery.py` — `Episode.chapters`; discovers the `.info.json` sidecar. - `feed.py` — declares `xmlns:podcast`, emits `<podcast:chapters url type>` and writes `{stem}.chapters.json`. **Docs** - `docs/ytdl-sub-integration.md` — documents `output_options.info_json_name` + the `.info.json` sidecar, with a "Configuring episode chapters" section. - `README.md` — chapters bullet + input-format line. - `AGENTS.md` — `.info.json` input-contract row. **Cleanup** - Deleted `concept.md` and `improvements.md` (superseded — all tracked work is complete; CHANGELOG + PR history is the record). Chapters are optional: no `.info.json` sidecar (or an empty `chapters` array) → 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.
✨ emit Podcast 2.0 chapters from ytdl-sub info.json
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 2m33s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m50s
be6f3a6ce1
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
concept.md Outdated
@ -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.json sidecar when it contains chapters'.

**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.json` sidecar when it contains chapters'. <!-- wuming:sha256:dfda85615f7e92ffc1107abf18ec24f52de348ee93fad9755291e4a1ed5545ce -->
marvin8 marked this conversation as resolved
✏️ clarify chapters emitted only when non-empty
All checks were successful
/ gitleaks (pull_request) Successful in 15s
/ pr-review (pull_request) Successful in 2m54s
/ checks (pull_request) Successful in 2m58s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
6b9feb823a
Author
Collaborator

concept.md line 58

The parenthetical '(when present)' is ambiguous…

✅ Fixed in commit 6b9feb8 — reworded to "from the .info.json sidecar when it contains chapters".

[`concept.md` line 58](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1781) > The parenthetical '(when present)' is ambiguous… ✅ Fixed in commit `6b9feb8` — reworded to "from the `.info.json` sidecar when it contains chapters".
forgejo-actions left a comment

WuMing

Found 5 issue(s). See inline comments below.

## WuMing Found **5** issue(s). See inline comments below.
Release-Notes.md Outdated
@ -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 named chapters.json. Saying "a chapters.json sidecar" is ambiguous; use "a .chapters.json sidecar" or "a {stem}.chapters.json sidecar" to match the actual filename pattern.

**docs** [LOW] The implementation writes `{stem}.chapters.json`, not a file literally named `chapters.json`. Saying "a `chapters.json` sidecar" is ambiguous; use "a `.chapters.json` sidecar" or "a `{stem}.chapters.json` sidecar" to match the actual filename pattern. <!-- wuming:sha256:07bf6433659307044e8d3428cda6dc2a8957015b2194368f1cc4f42db4de2444 -->
marvin8 marked this conversation as resolved
concept.md Outdated
@ -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 chapters

docs [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 generated chapters.json sidecar, populated from the .info.json when it contains chapters".

**docs** [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 generated `chapters.json` sidecar, populated from the `.info.json` when it contains chapters". <!-- wuming:sha256:1123c8ddbe50588d3c2a7fbf0318a7da0170a86e325f16d34d9283f288280201 -->
marvin8 marked this conversation as resolved
@ -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_chapters promises to return [] on malformed input, but it only catches OSError and json.JSONDecodeError. If the JSON is valid but the top-level value is not a dict (for example [] or "str"), or data.get("chapters") returns a non-list, or a chapter entry is not a dict, this line raises an unhandled AttributeError/TypeError and can abort discovery. Validate the JSON shape after json.loads (e.g., if not isinstance(data, dict): return [], and similarly check chapters and each raw).

**code** [MEDIUM] `parse_chapters` promises to return `[]` on malformed input, but it only catches `OSError` and `json.JSONDecodeError`. If the JSON is valid but the top-level value is not a dict (for example `[]` or `"str"`), or `data.get("chapters")` returns a non-list, or a chapter entry is not a dict, this line raises an unhandled `AttributeError`/`TypeError` and can abort discovery. Validate the JSON shape after `json.loads` (e.g., `if not isinstance(data, dict): return []`, and similarly check `chapters` and each `raw`). <!-- wuming:sha256:616fbd9ae1790005128217061841250fd06c41f51b0e857d0f8c3e67aaa9ee9c -->
marvin8 marked this conversation as resolved
@ -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_time is explicitly allowed to be a float, but int(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.

**code** [LOW] `start_time` is explicitly allowed to be a float, but `int(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. <!-- wuming:sha256:7a6ef8d286cf7e05d35429195326f62ae1e117c807432bb35fa201adb653df15 -->
marvin8 marked this conversation as resolved
@ -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_json could emit wrong keys, an empty payload, or truncated start times and this test would still pass. Parse the file and assert the version and chapter array contents.

**code** [LOW] This test only asserts that the chapters JSON file exists; it does not verify the serialized content. `chapters_to_json` could emit wrong keys, an empty payload, or truncated start times and this test would still pass. Parse the file and assert the `version` and chapter array contents. <!-- wuming:sha256:e3ee69497ba392184d04d7d976f87c345457d926982a567571d5ce5a970f0cf0 -->
marvin8 marked this conversation as resolved
✏️ clarify chapters docs wording
All checks were successful
/ gitleaks (pull_request) Successful in 14s
/ checks (pull_request) Successful in 2m28s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m45s
7afa833f43
Author
Collaborator

WuMing review — all 5 addressed

src/sub2pod/chapters.py line 48

parse_chapters only catches OSError and JSONDecodeError; malformed shapes raise AttributeError/TypeError…

✅ Fixed in commit b632164 — validates data is a dict, chapters is a list, and skips non-dict entries. Added three tests (JSON-is-a-list, chapters-not-a-list, non-dict entry).

src/sub2pod/chapters.py line 52

int(start_time) truncates fractional seconds…

✅ Fixed in commit b632164 — now uses round() instead of int().

tests/test_feed.py line 712

This test only asserts the file exists, not its content…

✅ Fixed in commit b632164 — the test now parses the written JSON and asserts version and the chapter array.

concept.md line 58

The element is a pointer, not inline; wording could be read as embedded…

✅ Fixed in commit 7afa833 — reworded to "referencing the generated .chapters.json sidecar, populated from the .info.json when it contains chapters".

Release-Notes.md line 23

"chapters.json" is ambiguous vs the actual {stem}.chapters.json…

✅ Fixed in commit 7afa833 — now says "a .chapters.json sidecar".

### WuMing review — all 5 addressed [`src/sub2pod/chapters.py` line 48](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1785) > parse_chapters only catches OSError and JSONDecodeError; malformed shapes raise AttributeError/TypeError… ✅ Fixed in commit `b632164` — validates `data` is a dict, `chapters` is a list, and skips non-dict entries. Added three tests (JSON-is-a-list, chapters-not-a-list, non-dict entry). [`src/sub2pod/chapters.py` line 52](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1786) > int(start_time) truncates fractional seconds… ✅ Fixed in commit `b632164` — now uses `round()` instead of `int()`. [`tests/test_feed.py` line 712](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1787) > This test only asserts the file exists, not its content… ✅ Fixed in commit `b632164` — the test now parses the written JSON and asserts `version` and the chapter array. [`concept.md` line 58](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1788) > The element is a pointer, not inline; wording could be read as embedded… ✅ Fixed in commit `7afa833` — reworded to "referencing the generated `.chapters.json` sidecar, populated from the `.info.json` when it contains chapters". [`Release-Notes.md` line 23](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1789) > "chapters.json" is ambiguous vs the actual `{stem}.chapters.json`… ✅ Fixed in commit `7afa833` — now says "a `.chapters.json` sidecar".
🔥 remove superseded concept.md and improvements.md
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 2m13s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m21s
4ca6b34aeb
forgejo-actions left a comment

WuMing

Found 2 issue(s). See inline comments below.

## WuMing Found **2** issue(s). See inline comments below.
README.md Outdated
@ -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.json file 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.

**docs** [LOW] The `.info.json` file 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. <!-- wuming:sha256:42304c6284a23be0c30b3b165412177efbcc27aa2070db12ba147cada42af69f -->
marvin8 marked this conversation as resolved
@ -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 and

docs [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.

**docs** [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:sha256:46244da33cac513cd24df58ac0bae737a00b061e5fe6b4e8f9752af501ec298f -->
marvin8 marked this conversation as resolved
✏️ clarify chapters sidecar naming and location in docs
All checks were successful
/ gitleaks (pull_request) Successful in 19s
/ checks (pull_request) Successful in 2m21s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m22s
db9c3e4263
Author
Collaborator

WuMing review — both addressed

docs/ytdl-sub-integration.md line 134

The {stem} placeholder is not defined and the file location is omitted…

✅ Fixed in commit db9c3e4 — reworded to "writes a .chapters.json file beside the audio, named after the episode (e.g. 2026-05-19 - Episode Title.chapters.json), and references it from the feed item".

README.md line 105

The .info.json file is yt-dlp metadata, not the sub2pod-generated chapters sidecar…

✅ Fixed in commit db9c3e4 — relabelled to "yt-dlp metadata sidecar (chapters source, optional)" in README, and aligned the same wording in AGENTS.md.

### WuMing review — both addressed [`docs/ytdl-sub-integration.md` line 134](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1794) > The `{stem}` placeholder is not defined and the file location is omitted… ✅ Fixed in commit `db9c3e4` — reworded to "writes a `.chapters.json` file beside the audio, named after the episode (e.g. `2026-05-19 - Episode Title.chapters.json`), and references it from the feed item". [`README.md` line 105](https://forge.marvin8.zone/marvin8/sub2pod/pulls/38#issuecomment-1795) > The `.info.json` file is yt-dlp metadata, not the sub2pod-generated chapters sidecar… ✅ Fixed in commit `db9c3e4` — relabelled to "yt-dlp metadata sidecar (chapters source, optional)" in README, and aligned the same wording in AGENTS.md.
marvin8 approved these changes 2026-08-20 09:44:01 +00:00
marvin8 manually merged commit 1f0a82fd39 into main 2026-08-20 09:45:02 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/sub2pod!38
No description provided.