From e2b677d7d1779ab506186273877a5503d65ef5fd Mon Sep 17 00:00:00 2001 From: ChadFarrow Date: Sun, 15 Feb 2026 08:09:59 -0500 Subject: [PATCH] feat: auto-generate Podcasting 2.0 chapters from episode timestamps Parse timestamp lines in episode content at build time and generate JSON chapters files for podcast apps. Episodes with an existing chaptersUrl are skipped. Also passes through transcript/chapters URLs in the client-side RSS generator. Co-Authored-By: Claude Opus 4.6 --- NIP.md | 2 + scripts/build-rss.ts | 28 ++++++- src/lib/parseChapters.test.ts | 148 ++++++++++++++++++++++++++++++++++ src/lib/parseChapters.ts | 46 +++++++++++ src/lib/rssGenerator.ts | 4 + 5 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 src/lib/parseChapters.test.ts create mode 100644 src/lib/parseChapters.ts diff --git a/NIP.md b/NIP.md index e011af7..1ad32c7 100644 --- a/NIP.md +++ b/NIP.md @@ -32,6 +32,8 @@ A `kind 30054` event represents a podcast episode. These are addressable events - `image` - Episode artwork URL - `duration` - Episode duration in seconds (integer) - `t` - Topic tags for categorization (multiple tags allowed) +- `transcript` - URL to a transcript file (Podcasting 2.0 ``) +- `chapters` - URL to a JSON chapters file (Podcasting 2.0 `application/json+chapters`) - `edit` - Reference to original event ID when updating an episode (for edit history) #### Content Field diff --git a/scripts/build-rss.ts b/scripts/build-rss.ts index ef96612..660d2e3 100644 --- a/scripts/build-rss.ts +++ b/scripts/build-rss.ts @@ -4,6 +4,7 @@ import { nip19 } from 'nostr-tools'; import { NRelay1, NostrEvent } from '@nostrify/nostrify'; import type { PodcastEpisode, PodcastTrailer } from '../src/types/podcast.js'; import { PODCAST_CONFIG, PodcastConfig } from '../src/lib/podcastConfig.js'; +import { parseChaptersFromContent, generateChaptersJSON } from '../src/lib/parseChapters.js'; // Import naddr encoding function import { encodeEpisodeAsNaddr } from '../src/lib/nip19Utils.js'; @@ -608,16 +609,35 @@ async function buildRSS() { console.log('🔌 Relay queries completed'); } + // Auto-generate chapters JSON for episodes without a chaptersUrl + const baseUrl = finalConfig.podcast.website || 'https://podstr.example'; + const distDir = path.resolve('dist'); + const chaptersDir = path.join(distDir, 'chapters'); + await fs.mkdir(chaptersDir, { recursive: true }); + + let chaptersGenerated = 0; + for (const episode of episodes) { + if (episode.chaptersUrl) continue; // already has uploaded chapters + if (!episode.content) continue; + + const chapters = parseChaptersFromContent(episode.content); + if (chapters.length > 0) { + const filename = `${episode.identifier}.json`; + await fs.writeFile(path.join(chaptersDir, filename), generateChaptersJSON(chapters), 'utf-8'); + episode.chaptersUrl = `${baseUrl}/chapters/${filename}`; + chaptersGenerated++; + } + } + if (chaptersGenerated > 0) { + console.log(`📑 Auto-generated chapters for ${chaptersGenerated} episodes`); + } + console.log(`📊 Generating RSS with ${episodes.length} episodes and ${trailers.length} trailers`); console.log(`🔍 OP3 Analytics: ${finalConfig.podcast.useOP3 ? 'ENABLED' : 'DISABLED'}`); // Generate RSS feed with fetched data const rssContent = generateRSSFeed(episodes, trailers, finalConfig); - // Ensure dist directory exists - const distDir = path.resolve('dist'); - await fs.mkdir(distDir, { recursive: true }); - // Write RSS file const rssPath = path.join(distDir, 'rss.xml'); await fs.writeFile(rssPath, rssContent, 'utf-8'); diff --git a/src/lib/parseChapters.test.ts b/src/lib/parseChapters.test.ts new file mode 100644 index 0000000..7739092 --- /dev/null +++ b/src/lib/parseChapters.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { parseChaptersFromContent, generateChaptersJSON } from './parseChapters'; + +describe('parseChaptersFromContent', () => { + it('parses MM:SS format', () => { + const content = `CHAPTERS +00:00 Intro +00:15 Welcome to Soapbox Sessions +01:00 Derek's Nashville Travel Recap`; + + const chapters = parseChaptersFromContent(content); + expect(chapters).toEqual([ + { startTime: 0, title: 'Intro' }, + { startTime: 15, title: 'Welcome to Soapbox Sessions' }, + { startTime: 60, title: "Derek's Nashville Travel Recap" }, + ]); + }); + + it('parses H:MM:SS format', () => { + const content = `CHAPTERS +00:00 Rebuilding the Internet: A New Perspective +08:58 Upcoming Events in the Decentralized Space +1:00:12 Wrap Up`; + + const chapters = parseChaptersFromContent(content); + expect(chapters).toEqual([ + { startTime: 0, title: 'Rebuilding the Internet: A New Perspective' }, + { startTime: 538, title: 'Upcoming Events in the Decentralized Space' }, + { startTime: 3612, title: 'Wrap Up' }, + ]); + }); + + it('parses HH:MM:SS format', () => { + const content = `### Timestamps + +00:00 Nostr Development Updates +00:19 rust-nostr Ships Major API Redesign +01:45:35 Outro`; + + const chapters = parseChaptersFromContent(content); + expect(chapters).toEqual([ + { startTime: 0, title: 'Nostr Development Updates' }, + { startTime: 19, title: 'rust-nostr Ships Major API Redesign' }, + { startTime: 6335, title: 'Outro' }, + ]); + }); + + it('parses dash-separated format', () => { + const content = `00:00 - Introduction to Nostr Compass Episode 5 +01:01 - BitChat Security Audit Insights: Cure53 findings and 17+ PRs +53:04 - Conclusion and Future Developments`; + + const chapters = parseChaptersFromContent(content); + expect(chapters).toEqual([ + { startTime: 0, title: 'Introduction to Nostr Compass Episode 5' }, + { startTime: 61, title: 'BitChat Security Audit Insights: Cure53 findings and 17+ PRs' }, + { startTime: 3184, title: 'Conclusion and Future Developments' }, + ]); + }); + + it('handles em dash and en dash separators', () => { + const content = `00:00 – Intro with en dash +05:30 — Middle with em dash`; + + const chapters = parseChaptersFromContent(content); + expect(chapters).toEqual([ + { startTime: 0, title: 'Intro with en dash' }, + { startTime: 330, title: 'Middle with em dash' }, + ]); + }); + + it('ignores non-timestamp lines and headers', () => { + const content = `This is the show notes for an episode. + +Chapters: + 00:00 Intro + 00:15 Welcome + +Check us out at example.com`; + + const chapters = parseChaptersFromContent(content); + expect(chapters).toEqual([ + { startTime: 0, title: 'Intro' }, + { startTime: 15, title: 'Welcome' }, + ]); + }); + + it('returns empty array for fewer than 2 chapters', () => { + const content = `Just a single timestamp: 00:00 Intro`; + expect(parseChaptersFromContent(content)).toEqual([]); + }); + + it('returns empty array for no timestamps', () => { + const content = 'Just some episode notes with no timestamps at all.'; + expect(parseChaptersFromContent(content)).toEqual([]); + }); + + it('sorts chapters by startTime', () => { + const content = `05:00 Middle +00:00 Start +10:00 End`; + + const chapters = parseChaptersFromContent(content); + expect(chapters[0].startTime).toBe(0); + expect(chapters[1].startTime).toBe(300); + expect(chapters[2].startTime).toBe(600); + }); + + it('handles titles with colons', () => { + const content = `00:00 Topic: Subtopic +05:00 Another: One: Here`; + + const chapters = parseChaptersFromContent(content); + expect(chapters[0].title).toBe('Topic: Subtopic'); + expect(chapters[1].title).toBe('Another: One: Here'); + }); +}); + +describe('generateChaptersJSON', () => { + it('generates valid Podcasting 2.0 JSON', () => { + const chapters = [ + { startTime: 0, title: 'Intro' }, + { startTime: 538, title: 'Upcoming Events' }, + ]; + + const json = generateChaptersJSON(chapters); + const parsed = JSON.parse(json); + + expect(parsed.version).toBe('1.2.0'); + expect(parsed.chapters).toEqual([ + { startTime: 0, title: 'Intro' }, + { startTime: 538, title: 'Upcoming Events' }, + ]); + }); + + it('strips extra fields like img and url', () => { + const chapters = [ + { startTime: 0, title: 'Intro', img: 'http://example.com/img.jpg', url: 'http://example.com' }, + { startTime: 60, title: 'End' }, + ]; + + const json = generateChaptersJSON(chapters); + const parsed = JSON.parse(json); + + expect(parsed.chapters[0]).toEqual({ startTime: 0, title: 'Intro' }); + expect(parsed.chapters[1]).toEqual({ startTime: 60, title: 'End' }); + }); +}); diff --git a/src/lib/parseChapters.ts b/src/lib/parseChapters.ts new file mode 100644 index 0000000..19209e9 --- /dev/null +++ b/src/lib/parseChapters.ts @@ -0,0 +1,46 @@ +import type { PodcastChapter } from '@/types/podcast'; + +/** Regex to match timestamp lines: MM:SS, H:MM:SS, or HH:MM:SS with optional separator before title */ +const TIMESTAMP_RE = /^\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*[-–—]?\s*(.+)$/; + +/** Convert a timestamp string to seconds */ +function timestampToSeconds(ts: string): number { + const parts = ts.split(':').map(Number); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + return parts[0] * 60 + parts[1]; +} + +/** Parse timestamp lines from episode content into chapters */ +export function parseChaptersFromContent(content: string): PodcastChapter[] { + const chapters: PodcastChapter[] = []; + + for (const line of content.split('\n')) { + const match = line.match(TIMESTAMP_RE); + if (match) { + chapters.push({ + startTime: timestampToSeconds(match[1]), + title: match[2].trim(), + }); + } + } + + // Require at least 2 chapters to avoid false positives + if (chapters.length < 2) { + return []; + } + + // Sort by startTime ascending + chapters.sort((a, b) => a.startTime - b.startTime); + + return chapters; +} + +/** Generate Podcasting 2.0 JSON chapters string */ +export function generateChaptersJSON(chapters: PodcastChapter[]): string { + return JSON.stringify({ + version: '1.2.0', + chapters: chapters.map(({ startTime, title }) => ({ startTime, title })), + }, null, 2); +} diff --git a/src/lib/rssGenerator.ts b/src/lib/rssGenerator.ts index 4d5a8ec..3f6e7fc 100644 --- a/src/lib/rssGenerator.ts +++ b/src/lib/rssGenerator.ts @@ -35,6 +35,8 @@ function episodeToRSSItem(episode: PodcastEpisode, config?: PodcastConfig): RSSI seasonNumber: episode.seasonNumber, explicit: episode.explicit, image: episode.imageUrl, + transcriptUrl: episode.transcriptUrl, + chaptersUrl: episode.chaptersUrl, // Per-episode value splits (overrides podcast defaults) value: episode.value, }; @@ -198,6 +200,8 @@ export function generateRSSFeed(episodes: PodcastEpisode[], config?: PodcastConf ${escapeXml(item.guid)} + ${item.transcriptUrl ? `` : ''} + ${item.chaptersUrl ? `` : ''} ${item.value && item.value.enabled && item.value.recipients && item.value.recipients.length > 0 ? ` ${item.value.recipients.map(recipient =>