Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<podcast:transcript>`)
- `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
Expand Down
28 changes: 24 additions & 4 deletions scripts/build-rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
148 changes: 148 additions & 0 deletions src/lib/parseChapters.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
46 changes: 46 additions & 0 deletions src/lib/parseChapters.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions src/lib/rssGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -198,6 +200,8 @@ export function generateRSSFeed(episodes: PodcastEpisode[], config?: PodcastConf

<!-- Podcasting 2.0 tags -->
<podcast:guid>${escapeXml(item.guid)}</podcast:guid>
${item.transcriptUrl ? `<podcast:transcript url="${escapeXml(item.transcriptUrl)}" type="text/plain" />` : ''}
${item.chaptersUrl ? `<podcast:chapters url="${escapeXml(item.chaptersUrl)}" type="application/json+chapters" />` : ''}
${item.value && item.value.enabled && item.value.recipients && item.value.recipients.length > 0 ?
`<podcast:value type="${item.value.currency || 'lightning'}" method="lightning">
${item.value.recipients.map(recipient =>
Expand Down