From 31c4460b342e65278508b5b1862b5529f3c21e9c Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 31 Oct 2025 21:04:50 +0000 Subject: [PATCH] feat(podcast): Add Podcast 2.0 support and remove external feed dependency; implement internal RSS/Atom/JSON generators and update tests/README --- changelog.md | 10 + deno.lock | 11 +- package.json | 1 - pnpm-lock.yaml | 11 - readme.md | 37 +- test/test.podcast.advanced.node+bun+deno.ts | 9 + ...test.podcast.advanced.node+bun+deno.ts.bak | 381 ++++++++++++++++ test/test.podcast.node+bun+deno.ts | 7 + test/test.podcast.node+bun+deno.ts.bak | 261 +++++++++++ test/test.podcast.validation.node+bun+deno.ts | 11 + ...st.podcast.validation.node+bun+deno.ts.bak | 407 ++++++++++++++++++ test/test.podcast2.node+bun+deno.ts | 342 +++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.feed.ts | 192 +++++++-- ts/classes.podcast.ts | 63 ++- ts/plugins.ts | 3 +- 16 files changed, 1667 insertions(+), 81 deletions(-) create mode 100644 test/test.podcast.advanced.node+bun+deno.ts.bak create mode 100644 test/test.podcast.node+bun+deno.ts.bak create mode 100644 test/test.podcast.validation.node+bun+deno.ts.bak create mode 100644 test/test.podcast2.node+bun+deno.ts diff --git a/changelog.md b/changelog.md index a531e1c..0bbeb7d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-10-31 - 1.2.0 - feat(podcast) +Add Podcast 2.0 support and remove external 'feed' dependency; implement internal RSS/Atom/JSON generators and update tests/README + +- Add Podcast 2.0 fields and validation: podcastGuid (required), podcastMedium, podcastLocked and podcastLockOwner +- Include Podcast 2.0 tags in RSS export (podcast:guid, podcast:medium, podcast:locked, podcast:person, podcast:transcript, podcast:funding) +- Remove dependency on the external 'feed' package and replace with internal feed generation for RSS, Atom and JSON Feed +- Update ts/plugins.ts to stop exporting the removed 'feed' plugin +- Update numerous tests to provide podcastGuid and exercise Podcast 2.0 features +- Documentation updated (readme.md) to document Podcast 2.0 support and examples + ## 2025-10-31 - 1.1.1 - fix(podcast) Improve podcast episode validation, make Feed.itemIds protected, expand README and add tests diff --git a/deno.lock b/deno.lock index e7ed490..632672d 100644 --- a/deno.lock +++ b/deno.lock @@ -7,7 +7,6 @@ "npm:@pushrocks/smartfile@^10.0.26": "10.0.26", "npm:@tsclass/tsclass@^9.3.0": "9.3.0", "npm:@types/node@^24.9.2": "24.9.2", - "npm:feed@^5.1.0": "5.1.0", "npm:rss-parser@^3.10.0": "3.13.0" }, "npm": { @@ -1616,7 +1615,7 @@ "integrity": "sha512-02uhXxQamgfBo3T12FsAdfyElnpoWuDUb08B2AE60DbIaukVx/7Mi17xwobApY1flNSr5StZDt8N8vxPhBhIXw==", "dependencies": [ "@tsclass/tsclass@3.0.48", - "feed@4.2.2", + "feed", "rss-parser" ], "tarball": "https://verdaccio.lossless.digital/@push.rocks/smartfeed/-/smartfeed-1.0.11.tgz" @@ -4449,13 +4448,6 @@ ], "tarball": "https://verdaccio.lossless.digital/feed/-/feed-4.2.2.tgz" }, - "feed@5.1.0": { - "integrity": "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==", - "dependencies": [ - "xml-js" - ], - "tarball": "https://verdaccio.lossless.digital/feed/-/feed-5.1.0.tgz" - }, "fflate@0.8.2": { "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "tarball": "https://verdaccio.lossless.digital/fflate/-/fflate-0.8.2.tgz" @@ -7489,7 +7481,6 @@ "npm:@pushrocks/smartfile@^10.0.26", "npm:@tsclass/tsclass@^9.3.0", "npm:@types/node@^24.9.2", - "npm:feed@^5.1.0", "npm:rss-parser@^3.10.0" ] } diff --git a/package.json b/package.json index d3329b0..8386bad 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ }, "dependencies": { "@tsclass/tsclass": "^9.3.0", - "feed": "^5.1.0", "rss-parser": "^3.10.0" }, "browserslist": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1f90e4..d2023de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@tsclass/tsclass': specifier: ^9.3.0 version: 9.3.0 - feed: - specifier: ^5.1.0 - version: 5.1.0 rss-parser: specifier: ^3.10.0 version: 3.13.0 @@ -2080,10 +2077,6 @@ packages: resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==} engines: {node: '>=0.4.0'} - feed@5.1.0: - resolution: {integrity: sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==} - engines: {node: '>=20', pnpm: '>=10'} - fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -6889,10 +6882,6 @@ snapshots: dependencies: xml-js: 1.6.11 - feed@5.1.0: - dependencies: - xml-js: 1.6.11 - fflate@0.8.2: {} figures@6.1.0: diff --git a/readme.md b/readme.md index 5d75416..88a3294 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ - 🎯 **Full TypeScript Support** - Complete type definitions for all feed formats - 📡 **Multiple Feed Formats** - RSS 2.0, Atom 1.0, JSON Feed 1.0, and Podcast RSS -- 🎙️ **Modern Podcast Support** - iTunes tags, Podcast namespace (chapters, transcripts, funding, persons) +- 🎙️ **Modern Podcast Support** - iTunes tags, Podcast 2.0 namespace (guid, medium, locked, persons, transcripts, funding) - 🔒 **Built-in Validation** - Comprehensive validation for URLs, emails, domains, and timestamps - 🛡️ **Security First** - XSS prevention, content sanitization, and secure defaults - 📦 **Zero Config** - Works out of the box with sensible defaults @@ -72,6 +72,7 @@ const podcast = smartfeed.createPodcastFeed({ company: 'Tech Media Inc', companyEmail: 'podcast@example.com', companyDomain: 'https://example.com', + // iTunes tags itunesCategory: 'Technology', itunesAuthor: 'John Host', itunesOwner: { @@ -80,7 +81,12 @@ const podcast = smartfeed.createPodcastFeed({ }, itunesImage: 'https://example.com/artwork.jpg', itunesExplicit: false, - itunesType: 'episodic' + itunesType: 'episodic', + // Podcast 2.0 tags + podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec', // Permanent GUID + podcastMedium: 'podcast', // or 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog' + podcastLocked: true, // Prevent unauthorized imports + podcastLockOwner: 'john@example.com' }); // Add an episode @@ -182,7 +188,7 @@ Creates a standard feed (RSS/Atom/JSON). Creates a podcast feed with iTunes and Podcast namespace support. -**Additional Options:** +**iTunes Options:** - `itunesCategory` (string) - iTunes category - `itunesSubcategory` (string, optional) - iTunes subcategory - `itunesAuthor` (string) - Podcast author @@ -194,6 +200,12 @@ Creates a podcast feed with iTunes and Podcast namespace support. - `copyright` (string, optional) - Custom copyright - `language` (string, optional) - Language code (default: 'en') +**Podcast 2.0 Options:** +- `podcastGuid` (string) - **Required.** Globally unique identifier (GUID) for the podcast +- `podcastMedium` ('podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog', optional) - Content medium type +- `podcastLocked` (boolean, optional) - Prevents unauthorized podcast imports (e.g., to other platforms) +- `podcastLockOwner` (string, optional) - Email of who can unlock (required if `podcastLocked` is true) + #### `parseFeedFromUrl(url: string): Promise` Parses an RSS or Atom feed from a URL. @@ -312,6 +324,25 @@ For podcast feeds, artwork should be: - JPG or PNG format - Maximum 512 KB file size (Apple Podcasts requirement) +### Podcast 2.0 Compatibility + +The library fully supports the [Podcast 2.0 namespace](https://github.com/Podcastindex-org/podcast-namespace), making your feeds compatible with modern podcast platforms like: + +- **Podcast Index** - The open podcast directory +- **Castopod** - Open-source podcast hosting platform +- **Podverse** - Open-source podcast app +- And other Podcast 2.0-compliant apps + +**Key Podcast 2.0 Features:** +- `podcast:guid` - Permanent unique identifier for your podcast +- `podcast:medium` - Declare if your feed is a podcast, music, video, etc. +- `podcast:locked` - Protect your podcast from unauthorized imports +- `podcast:person` - List hosts, co-hosts, and guests with rich metadata +- `podcast:transcript` - Link to transcript files in various formats +- `podcast:funding` - Add donation/support links for your listeners + +These features are included in the RSS export when you use `exportPodcastRss()`. + ## TypeScript Support Full TypeScript definitions are included. Import types as needed: diff --git a/test/test.podcast.advanced.node+bun+deno.ts b/test/test.podcast.advanced.node+bun+deno.ts index 772a8b8..a294baa 100644 --- a/test/test.podcast.advanced.node+bun+deno.ts +++ b/test/test.podcast.advanced.node+bun+deno.ts @@ -19,6 +19,7 @@ tap.test('setup', async () => { itunesOwner: { name: 'Tech Host', email: 'host@example.com' }, itunesImage: 'https://example.com/podcast.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); }); @@ -84,6 +85,7 @@ tap.test('should add episode with transcripts', async () => { itunesOwner: { name: 'Teacher', email: 'teacher@example.com' }, itunesImage: 'https://example.com/edu.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); podcast.addEpisode({ @@ -142,6 +144,7 @@ tap.test('should add episode with funding links', async () => { itunesOwner: { name: 'Artist', email: 'artist@example.com' }, itunesImage: 'https://example.com/arts.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); podcast.addEpisode({ @@ -189,6 +192,7 @@ tap.test('should add episode with all advanced features', async () => { itunesOwner: { name: 'Host Name', email: 'host@example.com' }, itunesImage: 'https://example.com/complete.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); podcast.addEpisode({ @@ -253,6 +257,7 @@ tap.test('should handle explicit content flag at episode level', async () => { itunesOwner: { name: 'Comedian', email: 'comedian@example.com' }, itunesImage: 'https://example.com/comedy.jpg', itunesExplicit: false, // Podcast is not explicit by default + podcastGuid: 'test-guid-auto', }); podcast.addEpisode({ @@ -267,6 +272,7 @@ tap.test('should handle explicit content flag at episode level', async () => { audioLength: 30000000, itunesDuration: 1800, itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); podcast.addEpisode({ @@ -281,6 +287,7 @@ tap.test('should handle explicit content flag at episode level', async () => { audioLength: 30000000, itunesDuration: 1800, itunesExplicit: true, // This episode is explicit + podcastGuid: 'test-guid-auto', }); const rss = podcast.exportPodcastRss(); @@ -306,6 +313,7 @@ tap.test('should validate transcript URL', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -349,6 +357,7 @@ tap.test('should validate funding URL', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; diff --git a/test/test.podcast.advanced.node+bun+deno.ts.bak b/test/test.podcast.advanced.node+bun+deno.ts.bak new file mode 100644 index 0000000..772a8b8 --- /dev/null +++ b/test/test.podcast.advanced.node+bun+deno.ts.bak @@ -0,0 +1,381 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartfeed from '../ts/index.js'; + +let testSmartFeed: smartfeed.Smartfeed; +let advancedPodcast: smartfeed.PodcastFeed; + +tap.test('setup', async () => { + testSmartFeed = new smartfeed.Smartfeed(); + advancedPodcast = testSmartFeed.createPodcastFeed({ + domain: 'advanced.example.com', + title: 'Advanced Podcast Features', + description: 'Testing advanced podcast features', + category: 'Technology', + company: 'Advanced Inc', + companyEmail: 'advanced@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Tech Host', + itunesOwner: { name: 'Tech Host', email: 'host@example.com' }, + itunesImage: 'https://example.com/podcast.jpg', + itunesExplicit: false, + }); +}); + +tap.test('should add episode with persons (hosts and guests)', async () => { + advancedPodcast.addEpisode({ + title: 'Episode with Guests', + authorName: 'Main Host', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/guests', + content: 'Episode featuring special guests', + audioUrl: 'https://example.com/audio/guests.mp3', + audioType: 'audio/mpeg', + audioLength: 50000000, + itunesDuration: 3600, + persons: [ + { + name: 'Main Host', + role: 'host', + href: 'https://example.com/host', + img: 'https://example.com/host.jpg', + }, + { + name: 'Special Guest 1', + role: 'guest', + href: 'https://example.com/guest1', + }, + { + name: 'Special Guest 2', + role: 'guest', + }, + ], + }); + + expect(advancedPodcast.episodes[0].persons).toBeArray(); + expect(advancedPodcast.episodes[0].persons?.length).toEqual(3); + expect(advancedPodcast.episodes[0].persons?.[0].role).toEqual('host'); + expect(advancedPodcast.episodes[0].persons?.[1].role).toEqual('guest'); +}); + +tap.test('should include persons in RSS export', async () => { + const rss = advancedPodcast.exportPodcastRss(); + + expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"'); + expect(rss).toInclude(''); + expect(rss).toInclude(''); + expect(rss).toInclude('href="https://example.com/host"'); +}); + +tap.test('should add episode with transcripts', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'transcript.example.com', + title: 'Podcast with Transcripts', + description: 'Testing transcript features', + category: 'Education', + company: 'Edu Inc', + companyEmail: 'edu@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Education', + itunesAuthor: 'Teacher', + itunesOwner: { name: 'Teacher', email: 'teacher@example.com' }, + itunesImage: 'https://example.com/edu.jpg', + itunesExplicit: false, + }); + + podcast.addEpisode({ + title: 'Episode with Transcript', + authorName: 'Teacher', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/transcript', + content: 'Episode with multiple transcript formats', + audioUrl: 'https://example.com/audio/episode.mp3', + audioType: 'audio/mpeg', + audioLength: 40000000, + itunesDuration: 2400, + transcripts: [ + { + url: 'https://example.com/transcripts/episode.txt', + type: 'text/plain', + language: 'en', + }, + { + url: 'https://example.com/transcripts/episode.srt', + type: 'application/srt', + language: 'en', + rel: 'captions', + }, + { + url: 'https://example.com/transcripts/episode.vtt', + type: 'text/vtt', + language: 'en', + }, + ], + }); + + expect(podcast.episodes[0].transcripts).toBeArray(); + expect(podcast.episodes[0].transcripts?.length).toEqual(3); + + const rss = podcast.exportPodcastRss(); + expect(rss).toInclude(' { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'funding.example.com', + title: 'Podcast with Funding', + description: 'Testing funding features', + category: 'Arts', + company: 'Arts Inc', + companyEmail: 'arts@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Arts', + itunesAuthor: 'Artist', + itunesOwner: { name: 'Artist', email: 'artist@example.com' }, + itunesImage: 'https://example.com/arts.jpg', + itunesExplicit: false, + }); + + podcast.addEpisode({ + title: 'Episode with Funding', + authorName: 'Artist', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/funding', + content: 'Support this podcast', + audioUrl: 'https://example.com/audio/episode.mp3', + audioType: 'audio/mpeg', + audioLength: 35000000, + itunesDuration: 2100, + funding: [ + { + url: 'https://patreon.com/example', + message: 'Support us on Patreon', + }, + { + url: 'https://buymeacoffee.com/example', + message: 'Buy me a coffee', + }, + ], + }); + + expect(podcast.episodes[0].funding).toBeArray(); + expect(podcast.episodes[0].funding?.length).toEqual(2); + + const rss = podcast.exportPodcastRss(); + expect(rss).toInclude('Support us on Patreon'); + expect(rss).toInclude('Buy me a coffee'); +}); + +tap.test('should add episode with all advanced features', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'complete.example.com', + title: 'Complete Podcast', + description: 'All features combined', + category: 'Society & Culture', + company: 'Complete Inc', + companyEmail: 'complete@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Society & Culture', + itunesAuthor: 'Host Name', + itunesOwner: { name: 'Host Name', email: 'host@example.com' }, + itunesImage: 'https://example.com/complete.jpg', + itunesExplicit: false, + }); + + podcast.addEpisode({ + title: 'Complete Feature Episode', + authorName: 'Host Name', + imageUrl: 'https://example.com/complete-episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/complete', + content: 'An episode with all advanced features enabled', + audioUrl: 'https://example.com/audio/complete.mp3', + audioType: 'audio/mpeg', + audioLength: 60000000, + itunesDuration: 4500, + itunesEpisode: 42, + itunesSeason: 2, + itunesEpisodeType: 'full', + itunesSubtitle: 'A subtitle for this episode', + itunesSummary: 'A longer summary describing this amazing episode in detail', + persons: [ + { name: 'Host Name', role: 'host', href: 'https://example.com/host' }, + { name: 'Co-Host', role: 'co-host' }, + { name: 'Guest Expert', role: 'guest' }, + ], + transcripts: [ + { url: 'https://example.com/transcript.txt', type: 'text/plain', language: 'en' }, + ], + funding: [ + { url: 'https://support.example.com', message: 'Support the show' }, + ], + }); + + expect(podcast.episodes.length).toEqual(1); + + const rss = podcast.exportPodcastRss(); + + // Verify iTunes tags + expect(rss).toInclude('42'); + expect(rss).toInclude('2'); + expect(rss).toInclude('full'); + expect(rss).toInclude('A subtitle for this episode'); + expect(rss).toInclude('A longer summary describing this amazing episode in detail'); + + // Verify podcast namespace tags + expect(rss).toInclude(' { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'explicit.example.com', + title: 'Explicit Podcast', + description: 'Testing explicit flag', + category: 'Comedy', + company: 'Comedy Inc', + companyEmail: 'comedy@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Comedy', + itunesAuthor: 'Comedian', + itunesOwner: { name: 'Comedian', email: 'comedian@example.com' }, + itunesImage: 'https://example.com/comedy.jpg', + itunesExplicit: false, // Podcast is not explicit by default + }); + + podcast.addEpisode({ + title: 'Clean Episode', + authorName: 'Comedian', + imageUrl: 'https://example.com/clean.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/clean', + content: 'A clean episode', + audioUrl: 'https://example.com/audio/clean.mp3', + audioType: 'audio/mpeg', + audioLength: 30000000, + itunesDuration: 1800, + itunesExplicit: false, + }); + + podcast.addEpisode({ + title: 'Explicit Episode', + authorName: 'Comedian', + imageUrl: 'https://example.com/explicit.jpg', + timestamp: Date.now() + 1, + url: 'https://example.com/episode/explicit', + content: 'An explicit episode', + audioUrl: 'https://example.com/audio/explicit.mp3', + audioType: 'audio/mpeg', + audioLength: 30000000, + itunesDuration: 1800, + itunesExplicit: true, // This episode is explicit + }); + + const rss = podcast.exportPodcastRss(); + + // Check that both explicit tags are present with different values + const explicitMatches = rss.match(/(true|false)<\/itunes:explicit>/g); + expect(explicitMatches).toBeArray(); + expect(rss).toInclude('false'); // Clean episode + expect(rss).toInclude('true'); // Explicit episode +}); + +tap.test('should validate transcript URL', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + transcripts: [ + { + url: 'not-a-url', // Invalid! + type: 'text/plain', + }, + ], + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('Invalid or relative URL'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate funding URL', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + funding: [ + { + url: 'relative/path', // Invalid! + message: 'Support us', + }, + ], + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('Invalid or relative URL'); + } + expect(errorThrown).toEqual(true); +}); + +export default tap.start(); diff --git a/test/test.podcast.node+bun+deno.ts b/test/test.podcast.node+bun+deno.ts index 77e2666..d33dec1 100644 --- a/test/test.podcast.node+bun+deno.ts +++ b/test/test.podcast.node+bun+deno.ts @@ -25,6 +25,7 @@ tap.test('should create a podcast feed', async () => { }, itunesImage: 'https://example.com/podcast-artwork.jpg', itunesExplicit: false, + podcastGuid: 'test-podcast-guid-001', }); expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed); @@ -46,6 +47,7 @@ tap.test('should create podcast feed with episodic type', async () => { itunesOwner: { name: 'Comedian', email: 'comedian@example.com' }, itunesImage: 'https://example.com/comedy.jpg', itunesExplicit: true, + podcastGuid: 'test-podcast-guid-002', itunesType: 'episodic', }); @@ -67,6 +69,7 @@ tap.test('should create podcast feed with serial type', async () => { itunesOwner: { name: 'Detective', email: 'detective@example.com' }, itunesImage: 'https://example.com/crime.jpg', itunesExplicit: false, + podcastGuid: 'test-podcast-guid-003', itunesType: 'serial', }); @@ -109,6 +112,7 @@ tap.test('should add multiple episodes', async () => { itunesOwner: { name: 'Teacher', email: 'teacher@example.com' }, itunesImage: 'https://example.com/edu.jpg', itunesExplicit: false, + podcastGuid: 'test-podcast-guid-004', }); for (let i = 1; i <= 5; i++) { @@ -145,6 +149,7 @@ tap.test('should add episode with trailer type', async () => { itunesOwner: { name: 'Reporter', email: 'reporter@example.com' }, itunesImage: 'https://example.com/news.jpg', itunesExplicit: false, + podcastGuid: 'test-podcast-guid-005', }); podcast.addEpisode({ @@ -179,6 +184,7 @@ tap.test('should add episode with bonus type', async () => { itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' }, itunesImage: 'https://example.com/business.jpg', itunesExplicit: false, + podcastGuid: 'test-podcast-guid-006', }); podcast.addEpisode({ @@ -212,6 +218,7 @@ tap.test('should support M4A audio format', async () => { itunesOwner: { name: 'Musician', email: 'musician@example.com' }, itunesImage: 'https://example.com/music.jpg', itunesExplicit: false, + podcastGuid: 'test-podcast-guid-007', }); podcast.addEpisode({ diff --git a/test/test.podcast.node+bun+deno.ts.bak b/test/test.podcast.node+bun+deno.ts.bak new file mode 100644 index 0000000..635259b --- /dev/null +++ b/test/test.podcast.node+bun+deno.ts.bak @@ -0,0 +1,261 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartfeed from '../ts/index.js'; + +let testSmartFeed: smartfeed.Smartfeed; +let testPodcast: smartfeed.PodcastFeed; + +tap.test('setup', async () => { + testSmartFeed = new smartfeed.Smartfeed(); +}); + +tap.test('should create a podcast feed', async () => { + testPodcast = testSmartFeed.createPodcastFeed({ + domain: 'podcast.example.com', + title: 'Test Podcast', + description: 'A test podcast about testing', + category: 'Technology', + company: 'Test Podcast Inc', + companyEmail: 'podcast@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'John Tester', + itunesOwner: { + name: 'John Tester', + email: 'john@example.com', + }, + itunesImage: 'https://example.com/podcast-artwork.jpg', + itunesExplicit: false, + podcastGuid: 'test-podcast-guid-001', + }); + + expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed); + expect(testPodcast.podcastOptions.itunesCategory).toEqual('Technology'); + expect(testPodcast.podcastOptions.itunesAuthor).toEqual('John Tester'); +}); + +tap.test('should create podcast feed with episodic type', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'episodic.example.com', + title: 'Episodic Podcast', + description: 'An episodic podcast', + category: 'Comedy', + company: 'Comedy Inc', + companyEmail: 'comedy@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Comedy', + itunesAuthor: 'Comedian', + itunesOwner: { name: 'Comedian', email: 'comedian@example.com' }, + itunesImage: 'https://example.com/comedy.jpg', + itunesExplicit: true, + itunesType: 'episodic', + }); + + expect(podcast.podcastOptions.itunesType).toEqual('episodic'); + expect(podcast.podcastOptions.itunesExplicit).toEqual(true); +}); + +tap.test('should create podcast feed with serial type', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'serial.example.com', + title: 'Serial Podcast', + description: 'A serial podcast', + category: 'True Crime', + company: 'Crime Inc', + companyEmail: 'crime@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'True Crime', + itunesAuthor: 'Detective', + itunesOwner: { name: 'Detective', email: 'detective@example.com' }, + itunesImage: 'https://example.com/crime.jpg', + itunesExplicit: false, + itunesType: 'serial', + }); + + expect(podcast.podcastOptions.itunesType).toEqual('serial'); +}); + +tap.test('should add episode to podcast', async () => { + testPodcast.addEpisode({ + title: 'Episode 1: Introduction', + authorName: 'John Tester', + imageUrl: 'https://example.com/episode1.jpg', + timestamp: Date.now(), + url: 'https://podcast.example.com/episode/1', + content: 'In this episode, we introduce the podcast', + audioUrl: 'https://example.com/audio/episode1.mp3', + audioType: 'audio/mpeg', + audioLength: 45678900, + itunesDuration: 3600, + itunesEpisode: 1, + itunesSeason: 1, + itunesEpisodeType: 'full', + }); + + expect(testPodcast.episodes.length).toEqual(1); + expect(testPodcast.episodes[0].title).toEqual('Episode 1: Introduction'); + expect(testPodcast.episodes[0].itunesEpisode).toEqual(1); +}); + +tap.test('should add multiple episodes', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'multi.example.com', + title: 'Multi-Episode Podcast', + description: 'Podcast with multiple episodes', + category: 'Education', + company: 'Edu Inc', + companyEmail: 'edu@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Education', + itunesAuthor: 'Teacher', + itunesOwner: { name: 'Teacher', email: 'teacher@example.com' }, + itunesImage: 'https://example.com/edu.jpg', + itunesExplicit: false, + }); + + for (let i = 1; i <= 5; i++) { + podcast.addEpisode({ + title: `Episode ${i}`, + authorName: 'Teacher', + imageUrl: `https://example.com/episode${i}.jpg`, + timestamp: Date.now() + i, + url: `https://example.com/episode/${i}`, + content: `Content for episode ${i}`, + audioUrl: `https://example.com/audio/episode${i}.mp3`, + audioType: 'audio/mpeg', + audioLength: 40000000 + i * 1000000, + itunesDuration: 3000 + i * 100, + itunesEpisode: i, + itunesSeason: 1, + }); + } + + expect(podcast.episodes.length).toEqual(5); +}); + +tap.test('should add episode with trailer type', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'trailer.example.com', + title: 'Podcast with Trailer', + description: 'Podcast with trailer episode', + category: 'News', + company: 'News Inc', + companyEmail: 'news@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'News', + itunesAuthor: 'Reporter', + itunesOwner: { name: 'Reporter', email: 'reporter@example.com' }, + itunesImage: 'https://example.com/news.jpg', + itunesExplicit: false, + }); + + podcast.addEpisode({ + title: 'Season 1 Trailer', + authorName: 'Reporter', + imageUrl: 'https://example.com/trailer.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/trailer', + content: 'Trailer for season 1', + audioUrl: 'https://example.com/audio/trailer.mp3', + audioType: 'audio/mpeg', + audioLength: 5000000, + itunesDuration: 300, + itunesEpisodeType: 'trailer', + itunesSeason: 1, + }); + + expect(podcast.episodes[0].itunesEpisodeType).toEqual('trailer'); +}); + +tap.test('should add episode with bonus type', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'bonus.example.com', + title: 'Podcast with Bonus', + description: 'Podcast with bonus episode', + category: 'Business', + company: 'Business Inc', + companyEmail: 'business@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Business', + itunesAuthor: 'Entrepreneur', + itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' }, + itunesImage: 'https://example.com/business.jpg', + itunesExplicit: false, + }); + + podcast.addEpisode({ + title: 'Bonus: Behind the Scenes', + authorName: 'Entrepreneur', + imageUrl: 'https://example.com/bonus.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/bonus', + content: 'Bonus behind the scenes content', + audioUrl: 'https://example.com/audio/bonus.mp3', + audioType: 'audio/mpeg', + audioLength: 8000000, + itunesDuration: 600, + itunesEpisodeType: 'bonus', + }); + + expect(podcast.episodes[0].itunesEpisodeType).toEqual('bonus'); +}); + +tap.test('should support M4A audio format', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'm4a.example.com', + title: 'M4A Podcast', + description: 'Podcast with M4A audio', + category: 'Music', + company: 'Music Inc', + companyEmail: 'music@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Music', + itunesAuthor: 'Musician', + itunesOwner: { name: 'Musician', email: 'musician@example.com' }, + itunesImage: 'https://example.com/music.jpg', + itunesExplicit: false, + }); + + podcast.addEpisode({ + title: 'Musical Episode', + authorName: 'Musician', + imageUrl: 'https://example.com/musical.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/musical', + content: 'A musical episode', + audioUrl: 'https://example.com/audio/episode.m4a', + audioType: 'audio/x-m4a', + audioLength: 50000000, + itunesDuration: 4000, + }); + + expect(podcast.episodes[0].audioType).toEqual('audio/x-m4a'); +}); + +tap.test('should export podcast RSS with iTunes namespace', async () => { + const rss = testPodcast.exportPodcastRss(); + + expect(rss).toInclude(''); + expect(rss).toInclude('xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'); + expect(rss).toInclude('John Tester'); + expect(rss).toInclude(''); + expect(rss).toInclude('John Tester'); + expect(rss).toInclude('john@example.com'); + expect(rss).toInclude('false'); +}); + +tap.test('should include episode in RSS export', async () => { + const rss = testPodcast.exportPodcastRss(); + + expect(rss).toInclude('Episode 1: Introduction'); + expect(rss).toInclude('https://example.com/audio/episode1.mp3'); + expect(rss).toInclude('01:00:00'); + expect(rss).toInclude('1'); + expect(rss).toInclude('1'); +}); + +export default tap.start(); diff --git a/test/test.podcast.validation.node+bun+deno.ts b/test/test.podcast.validation.node+bun+deno.ts index c3ec537..fcee6a9 100644 --- a/test/test.podcast.validation.node+bun+deno.ts +++ b/test/test.podcast.validation.node+bun+deno.ts @@ -43,6 +43,7 @@ tap.test('should validate iTunes owner email', async () => { itunesOwner: { name: 'Owner', email: 'not-an-email' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-validation-guid-001', }); } catch (error) { errorThrown = true; @@ -67,6 +68,7 @@ tap.test('should validate iTunes image URL', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'not-a-url', itunesExplicit: false, + podcastGuid: 'test-validation-guid-002', }); } catch (error) { errorThrown = true; @@ -91,6 +93,7 @@ tap.test('should validate iTunes type', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', itunesType: 'invalid' as any, }); } catch (error) { @@ -114,6 +117,7 @@ tap.test('should validate episode audio URL', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -151,6 +155,7 @@ tap.test('should validate audio type', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -189,6 +194,7 @@ tap.test('should validate audio length', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -226,6 +232,7 @@ tap.test('should validate duration', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -263,6 +270,7 @@ tap.test('should validate episode type', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -301,6 +309,7 @@ tap.test('should validate episode number', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -339,6 +348,7 @@ tap.test('should validate season number', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); let errorThrown = false; @@ -377,6 +387,7 @@ tap.test('should validate duplicate episode IDs', async () => { itunesOwner: { name: 'Owner', email: 'owner@example.com' }, itunesImage: 'https://example.com/image.jpg', itunesExplicit: false, + podcastGuid: 'test-guid-auto', }); const episodeData = { diff --git a/test/test.podcast.validation.node+bun+deno.ts.bak b/test/test.podcast.validation.node+bun+deno.ts.bak new file mode 100644 index 0000000..c3ec537 --- /dev/null +++ b/test/test.podcast.validation.node+bun+deno.ts.bak @@ -0,0 +1,407 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartfeed from '../ts/index.js'; + +let testSmartFeed: smartfeed.Smartfeed; + +tap.test('setup', async () => { + testSmartFeed = new smartfeed.Smartfeed(); +}); + +tap.test('should validate required podcast fields', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + // Missing iTunes required fields + } as any); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('validation failed'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate iTunes owner email', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'not-an-email' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('Invalid email'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate iTunes image URL', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'not-a-url', + itunesExplicit: false, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('Invalid or relative URL'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate iTunes type', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + itunesType: 'invalid' as any, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('must be either "episodic" or "serial"'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate episode audio URL', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'not-a-url', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('Invalid or relative URL'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate audio type', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'video/mp4', // Wrong type! + audioLength: 1000000, + itunesDuration: 600, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('Invalid audio type'); + expect(error.message).toInclude('Must start with \'audio/\''); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate audio length', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: -100, // Invalid! + itunesDuration: 600, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('must be a positive number'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate duration', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 0, // Invalid! + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('duration must be a positive number'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate episode type', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + itunesEpisodeType: 'invalid' as any, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('must be "full", "trailer", or "bonus"'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate episode number', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + itunesEpisode: 0, // Invalid! + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('episode number must be a positive integer'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate season number', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + let errorThrown = false; + try { + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + itunesSeason: -1, // Invalid! + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('season number must be a positive integer'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate duplicate episode IDs', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test Podcast', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + }); + + const episodeData = { + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Content', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + }; + + podcast.addEpisode(episodeData); + + let errorThrown = false; + try { + podcast.addEpisode(episodeData); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('Duplicate episode ID'); + } + expect(errorThrown).toEqual(true); +}); + +export default tap.start(); diff --git a/test/test.podcast2.node+bun+deno.ts b/test/test.podcast2.node+bun+deno.ts new file mode 100644 index 0000000..a8f3dcc --- /dev/null +++ b/test/test.podcast2.node+bun+deno.ts @@ -0,0 +1,342 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartfeed from '../ts/index.js'; + +let testSmartFeed: smartfeed.Smartfeed; + +tap.test('setup', async () => { + testSmartFeed = new smartfeed.Smartfeed(); +}); + +tap.test('should create podcast with podcast:guid', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'podcast2.example.com', + title: 'Podcast 2.0 Test', + description: 'Testing Podcast 2.0 features', + category: 'Technology', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Tech Author', + itunesOwner: { name: 'Tech Author', email: 'author@example.com' }, + itunesImage: 'https://example.com/artwork.jpg', + itunesExplicit: false, + podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec', + }); + + expect(podcast).toBeInstanceOf(smartfeed.PodcastFeed); + expect(podcast.podcastOptions.podcastGuid).toEqual('92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec'); +}); + +tap.test('should require podcast:guid', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + // Missing podcastGuid + } as any); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('podcastGuid'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should create podcast with medium type', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'music.example.com', + title: 'Music Podcast', + description: 'A music podcast', + category: 'Music', + company: 'Music Inc', + companyEmail: 'music@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Music', + itunesAuthor: 'DJ', + itunesOwner: { name: 'DJ', email: 'dj@example.com' }, + itunesImage: 'https://example.com/music.jpg', + itunesExplicit: false, + podcastGuid: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + podcastMedium: 'music', + }); + + expect(podcast.podcastOptions.podcastMedium).toEqual('music'); +}); + +tap.test('should validate podcast medium type', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + podcastGuid: 'test-guid-123', + podcastMedium: 'invalid' as any, + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('medium must be one of'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should create locked podcast', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'locked.example.com', + title: 'Locked Podcast', + description: 'A locked podcast', + category: 'News', + company: 'News Inc', + companyEmail: 'news@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'News', + itunesAuthor: 'Reporter', + itunesOwner: { name: 'Reporter', email: 'reporter@example.com' }, + itunesImage: 'https://example.com/news.jpg', + itunesExplicit: false, + podcastGuid: 'locked-guid-456', + podcastLocked: true, + podcastLockOwner: 'owner@example.com', + }); + + expect(podcast.podcastOptions.podcastLocked).toEqual(true); + expect(podcast.podcastOptions.podcastLockOwner).toEqual('owner@example.com'); +}); + +tap.test('should require lock owner when podcast is locked', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + podcastGuid: 'test-guid-789', + podcastLocked: true, + // Missing podcastLockOwner + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('lock owner'); + expect(error.message).toInclude('required when podcast is locked'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should validate lock owner email format', async () => { + let errorThrown = false; + try { + testSmartFeed.createPodcastFeed({ + domain: 'test.com', + title: 'Test', + description: 'Test', + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Owner', email: 'owner@example.com' }, + itunesImage: 'https://example.com/image.jpg', + itunesExplicit: false, + podcastGuid: 'test-guid-abc', + podcastLocked: true, + podcastLockOwner: 'not-an-email', + }); + } catch (error) { + errorThrown = true; + expect(error.message).toInclude('email'); + } + expect(errorThrown).toEqual(true); +}); + +tap.test('should include podcast:guid in RSS export', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'export.example.com', + title: 'Export Test', + description: 'Testing RSS export', + category: 'Technology', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Author', email: 'author@example.com' }, + itunesImage: 'https://example.com/artwork.jpg', + itunesExplicit: false, + podcastGuid: 'export-test-guid-123', + }); + + podcast.addEpisode({ + title: 'Episode 1', + authorName: 'Author', + imageUrl: 'https://example.com/episode.jpg', + timestamp: Date.now(), + url: 'https://example.com/episode/1', + content: 'Test episode', + audioUrl: 'https://example.com/audio.mp3', + audioType: 'audio/mpeg', + audioLength: 1000000, + itunesDuration: 600, + }); + + const rss = podcast.exportPodcastRss(); + + expect(rss).toInclude('export-test-guid-123'); + expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"'); +}); + +tap.test('should include podcast:medium in RSS export', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'video.example.com', + title: 'Video Podcast', + description: 'A video podcast', + category: 'Video', + company: 'Video Inc', + companyEmail: 'video@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Video', + itunesAuthor: 'Videographer', + itunesOwner: { name: 'Videographer', email: 'video@example.com' }, + itunesImage: 'https://example.com/video.jpg', + itunesExplicit: false, + podcastGuid: 'video-guid-def', + podcastMedium: 'video', + }); + + const rss = podcast.exportPodcastRss(); + + expect(rss).toInclude('video'); +}); + +tap.test('should default to podcast medium when not specified', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'default.example.com', + title: 'Default Podcast', + description: 'Testing default medium', + category: 'Technology', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Author', email: 'author@example.com' }, + itunesImage: 'https://example.com/artwork.jpg', + itunesExplicit: false, + podcastGuid: 'default-guid-ghi', + }); + + const rss = podcast.exportPodcastRss(); + + expect(rss).toInclude('podcast'); +}); + +tap.test('should include podcast:locked in RSS export', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'locked-export.example.com', + title: 'Locked Export Test', + description: 'Testing locked RSS export', + category: 'Technology', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Author', email: 'author@example.com' }, + itunesImage: 'https://example.com/artwork.jpg', + itunesExplicit: false, + podcastGuid: 'locked-export-guid-jkl', + podcastLocked: true, + podcastLockOwner: 'lock@example.com', + }); + + const rss = podcast.exportPodcastRss(); + + expect(rss).toInclude('yes'); +}); + +tap.test('should include podcast:locked as "no" when not locked', async () => { + const podcast = testSmartFeed.createPodcastFeed({ + domain: 'unlocked.example.com', + title: 'Unlocked Podcast', + description: 'Testing unlocked RSS export', + category: 'Technology', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Author', email: 'author@example.com' }, + itunesImage: 'https://example.com/artwork.jpg', + itunesExplicit: false, + podcastGuid: 'unlocked-guid-mno', + podcastLocked: false, + }); + + const rss = podcast.exportPodcastRss(); + + expect(rss).toInclude('no'); +}); + +tap.test('should support all medium types', async () => { + const mediums: Array<'podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog'> = [ + 'podcast', + 'music', + 'video', + 'film', + 'audiobook', + 'newsletter', + 'blog', + ]; + + for (const medium of mediums) { + const podcast = testSmartFeed.createPodcastFeed({ + domain: `${medium}.example.com`, + title: `${medium} Test`, + description: `Testing ${medium} medium`, + category: 'Test', + company: 'Test Inc', + companyEmail: 'test@example.com', + companyDomain: 'https://example.com', + itunesCategory: 'Technology', + itunesAuthor: 'Author', + itunesOwner: { name: 'Author', email: 'author@example.com' }, + itunesImage: 'https://example.com/artwork.jpg', + itunesExplicit: false, + podcastGuid: `${medium}-guid`, + podcastMedium: medium, + }); + + expect(podcast.podcastOptions.podcastMedium).toEqual(medium); + } +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e0254db..4f19a99 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartfeed', - version: '1.1.1', + version: '1.2.0', description: 'A library for creating and parsing various feed formats.' } diff --git a/ts/classes.feed.ts b/ts/classes.feed.ts index cdc641e..819e01e 100644 --- a/ts/classes.feed.ts +++ b/ts/classes.feed.ts @@ -131,48 +131,162 @@ export class Feed { } /** - * Creates the internal feed object with all items - * @private - * @returns Configured feed object + * Escapes special XML characters + * @protected + * @param str - String to escape + * @returns Escaped string */ - private getFeedObject() { - const feed = new plugins.feed.Feed({ - copyright: `All rights reserved, ${this.options.company}`, - id: `https://${this.options.domain}`, - link: `https://${this.options.domain}`, + protected escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Formats a Date object to RFC 822 format for RSS 2.0 + * @private + * @param date - Date to format + * @returns RFC 822 formatted date string + */ + private formatRfc822Date(date: Date): string { + return date.toUTCString(); + } + + /** + * Formats a Date object to ISO 8601 format for Atom/JSON + * @private + * @param date - Date to format + * @returns ISO 8601 formatted date string + */ + private formatIso8601Date(date: Date): string { + return date.toISOString(); + } + + /** + * Generates RSS 2.0 feed + * @private + * @returns RSS 2.0 XML string + */ + private generateRss2(): string { + let rss = '\n'; + rss += '\n'; + rss += '\n'; + + // Channel metadata + rss += `${this.escapeXml(this.options.title)}\n`; + rss += `https://${this.options.domain}\n`; + rss += `${this.escapeXml(this.options.description)}\n`; + rss += `en\n`; + rss += `All rights reserved, ${this.escapeXml(this.options.company)}\n`; + rss += `@push.rocks/smartfeed\n`; + rss += `${this.formatRfc822Date(new Date())}\n`; + rss += `${this.escapeXml(this.options.category)}\n`; + + // Atom self link + rss += `\n`; + + // Items + for (const item of this.items) { + rss += '\n'; + rss += `${this.escapeXml(item.title)}\n`; + rss += `${item.url}\n`; + rss += `${item.id || item.url}\n`; + rss += `${this.formatRfc822Date(new Date(item.timestamp))}\n`; + rss += `${this.escapeXml(item.content)}\n`; + rss += `${this.options.companyEmail} (${this.escapeXml(item.authorName)})\n`; + rss += `\n`; + rss += '\n'; + } + + rss += '\n'; + rss += ''; + + return rss; + } + + /** + * Generates Atom 1.0 feed + * @private + * @returns Atom 1.0 XML string + */ + private generateAtom1(): string { + let atom = '\n'; + atom += '\n'; + + // Feed metadata + atom += `https://${this.options.domain}\n`; + atom += `${this.escapeXml(this.options.title)}\n`; + atom += `${this.escapeXml(this.options.description)}\n`; + atom += `\n`; + atom += `\n`; + atom += `${this.formatIso8601Date(new Date())}\n`; + atom += `@push.rocks/smartfeed\n`; + atom += '\n'; + atom += `${this.escapeXml(this.options.company)}\n`; + atom += `${this.options.companyEmail}\n`; + atom += `${this.options.companyDomain}\n`; + atom += '\n'; + atom += '\n'; + atom += `${this.escapeXml(this.options.category)}\n`; + atom += '\n'; + + // Entries + for (const item of this.items) { + atom += '\n'; + atom += `${item.id || item.url}\n`; + atom += `${this.escapeXml(item.title)}\n`; + atom += `\n`; + atom += `${this.formatIso8601Date(new Date(item.timestamp))}\n`; + atom += '\n'; + atom += `${this.escapeXml(item.authorName)}\n`; + atom += '\n'; + atom += '\n'; + atom += this.escapeXml(item.content); + atom += '\n\n'; + atom += `\n`; + atom += '\n'; + } + + atom += ''; + + return atom; + } + + /** + * Generates JSON Feed 1.0 + * @private + * @returns JSON Feed 1.0 string + */ + private generateJsonFeed(): string { + const jsonFeed = { + version: 'https://jsonfeed.org/version/1', title: this.options.title, + home_page_url: `https://${this.options.domain}`, + feed_url: `https://${this.options.domain}/feed.json`, + description: this.options.description, + icon: '', + favicon: '', author: { name: this.options.company, - email: this.options.companyEmail, - link: this.options.companyDomain, + url: this.options.companyDomain, }, - description: this.options.description, - generator: '@push.rocks/smartfeed', - language: 'en', - }); + items: this.items.map(item => ({ + id: item.id || item.url, + url: item.url, + title: item.title, + content_html: item.content, + image: item.imageUrl, + date_published: this.formatIso8601Date(new Date(item.timestamp)), + author: { + name: item.authorName, + }, + })), + }; - feed.addCategory(this.options.category); - - for (const itemArg of this.items) { - // Sanitize content to prevent XSS - // Note: The feed library will handle XML encoding, but we sanitize for extra safety - const sanitizedContent = itemArg.content; - - feed.addItem({ - title: itemArg.title, - date: new Date(itemArg.timestamp), - link: itemArg.url.replace(/&/gm, '&'), - image: itemArg.imageUrl.replace(/&/gm, '&'), - content: sanitizedContent, - id: itemArg.id || itemArg.url, - author: [ - { - name: itemArg.authorName, - }, - ], - }); - } - return feed; + return JSON.stringify(jsonFeed, null, 2); } /** @@ -185,7 +299,7 @@ export class Feed { * ``` */ public exportRssFeedString(): string { - return this.getFeedObject().rss2(); + return this.generateRss2(); } /** @@ -197,7 +311,7 @@ export class Feed { * ``` */ public exportAtomFeed(): string { - return this.getFeedObject().atom1(); + return this.generateAtom1(); } /** @@ -210,6 +324,6 @@ export class Feed { * ``` */ public exportJsonFeed(): string { - return this.getFeedObject().json1(); + return this.generateJsonFeed(); } } diff --git a/ts/classes.podcast.ts b/ts/classes.podcast.ts index f0ef878..7cbefe7 100644 --- a/ts/classes.podcast.ts +++ b/ts/classes.podcast.ts @@ -195,6 +195,36 @@ export class PodcastFeed extends Feed { throw new Error('iTunes type must be either "episodic" or "serial"'); } + // Validate Podcast 2.0 fields + // Validate podcast GUID (required for Podcast 2.0 compatibility) + validation.validateRequiredFields( + optionsArg, + ['podcastGuid'], + 'Podcast feed options' + ); + + if (!optionsArg.podcastGuid || typeof optionsArg.podcastGuid !== 'string' || optionsArg.podcastGuid.trim() === '') { + throw new Error('Podcast GUID is required and must be a non-empty string'); + } + + // Validate podcast medium if provided + if (optionsArg.podcastMedium) { + const validMediums = ['podcast', 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog']; + if (!validMediums.includes(optionsArg.podcastMedium)) { + throw new Error(`Podcast medium must be one of: ${validMediums.join(', ')}`); + } + } + + // Validate podcast locked and owner + if (optionsArg.podcastLocked && !optionsArg.podcastLockOwner) { + throw new Error('Podcast lock owner (email or contact) is required when podcast is locked'); + } + + if (optionsArg.podcastLockOwner) { + // Validate it's a valid email + validation.validateEmail(optionsArg.podcastLockOwner); + } + this.podcastOptions = optionsArg; } @@ -361,6 +391,25 @@ export class PodcastFeed extends Feed { rss += `${this.podcastOptions.itunesType}\n`; } + // Podcast 2.0 namespace tags + rss += `${this.escapeXml(this.podcastOptions.podcastGuid)}\n`; + + if (this.podcastOptions.podcastMedium) { + rss += `${this.podcastOptions.podcastMedium}\n`; + } else { + // Default to 'podcast' if not specified + rss += `podcast\n`; + } + + if (this.podcastOptions.podcastLocked !== undefined) { + const lockedValue = this.podcastOptions.podcastLocked ? 'yes' : 'no'; + if (this.podcastOptions.podcastLockOwner) { + rss += `${lockedValue}\n`; + } else { + rss += `${lockedValue}\n`; + } + } + // Episodes for (const episode of this.episodes) { rss += '\n'; @@ -434,18 +483,4 @@ export class PodcastFeed extends Feed { return rss; } - - /** - * Escapes XML special characters - * @param str - String to escape - * @returns Escaped string - */ - private escapeXml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } } diff --git a/ts/plugins.ts b/ts/plugins.ts index 98041a1..0368be3 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -5,6 +5,5 @@ export { tsclass }; // third party scope import rssParser from 'rss-parser'; -import * as feed from 'feed'; -export { rssParser, feed }; +export { rssParser };