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 =>