import * as plugins from './plugins.js'; import * as validation from './validation.js'; import { Feed } from './classes.feed.js'; import type { IFeedOptions, IFeedItem } from './classes.feed.js'; /** * iTunes podcast owner information */ export interface IPodcastOwner { /** Name of the podcast owner */ name: string; /** Email of the podcast owner */ email: string; } /** * Configuration options for creating a podcast feed * Extends standard feed options with iTunes-specific and Podcast 2.0 fields */ export interface IPodcastFeedOptions extends IFeedOptions { /** iTunes category (e.g., 'Technology', 'Comedy', 'News') */ itunesCategory: string; /** iTunes subcategory (optional) */ itunesSubcategory?: string; /** Podcast author name */ itunesAuthor: string; /** Podcast owner information */ itunesOwner: IPodcastOwner; /** URL to podcast artwork (1400x1400 to 3000x3000 pixels, JPG or PNG) */ itunesImage: string; /** Whether the podcast contains explicit content */ itunesExplicit: boolean; /** Podcast type: episodic (default) or serial */ itunesType?: 'episodic' | 'serial'; /** Podcast summary (optional, more detailed than description) */ itunesSummary?: string; /** Copyright notice (overrides default) */ copyright?: string; /** Language code (overrides default 'en') */ language?: string; // Podcast 2.0 namespace fields /** Globally unique identifier for the podcast (GUID) - required for Podcast 2.0 */ podcastGuid: string; /** The medium of the podcast content (defaults to 'podcast') */ podcastMedium?: 'podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog'; /** Whether the podcast is locked to prevent unauthorized imports (defaults to false) */ podcastLocked?: boolean; /** Email/contact of who can unlock the podcast if locked (required if podcastLocked is true) */ podcastLockOwner?: string; } /** * Person role in podcast episode (host, guest, etc.) */ export interface IPodcastPerson { /** Person's name */ name: string; /** Role (e.g., 'host', 'guest', 'producer') */ role?: string; /** URL to person's profile/website */ href?: string; /** Image URL for the person */ img?: string; } /** * Chapter marker in podcast episode */ export interface IPodcastChapter { /** Chapter start time in seconds */ startTime: number; /** Chapter title */ title: string; /** Chapter URL (optional) */ href?: string; /** Chapter image URL (optional) */ img?: string; } /** * Transcript information for podcast episode */ export interface IPodcastTranscript { /** URL to transcript file */ url: string; /** Transcript type (e.g., 'text/plain', 'text/html', 'application/srt') */ type: string; /** Language code (e.g., 'en', 'es') */ language?: string; /** Transcript relationship (e.g., 'captions') */ rel?: string; } /** * Funding/donation information */ export interface IPodcastFunding { /** URL to funding/donation page */ url: string; /** Funding message/call to action */ message: string; } /** * Represents a single podcast episode in the feed */ export interface IPodcastItem extends IFeedItem { /** URL to audio file (MP3, M4A, etc.) */ audioUrl: string; /** MIME type of audio file (e.g., 'audio/mpeg', 'audio/x-m4a') */ audioType: string; /** Size of audio file in bytes */ audioLength: number; // iTunes tags /** Episode duration in seconds */ itunesDuration: number; /** Episode number (for episodic podcasts) */ itunesEpisode?: number; /** Season number */ itunesSeason?: number; /** Episode type: full, trailer, or bonus */ itunesEpisodeType?: 'full' | 'trailer' | 'bonus'; /** Whether episode contains explicit content */ itunesExplicit?: boolean; /** Episode subtitle (short description) */ itunesSubtitle?: string; /** Episode summary (can be longer than content) */ itunesSummary?: string; // Modern podcast namespace /** People involved in episode (hosts, guests, etc.) */ persons?: IPodcastPerson[]; /** Chapter markers */ chapters?: IPodcastChapter[]; /** Transcripts */ transcripts?: IPodcastTranscript[]; /** Funding/donation links */ funding?: IPodcastFunding[]; } /** * Represents a podcast feed that can generate RSS with iTunes and Podcast namespaces * @example * ```typescript * const podcast = new PodcastFeed({ * domain: 'podcast.example.com', * title: 'My Awesome Podcast', * description: 'A podcast about awesome things', * category: 'Technology', * company: 'Podcast Inc', * companyEmail: 'podcast@example.com', * companyDomain: 'https://example.com', * itunesCategory: 'Technology', * itunesAuthor: 'John Doe', * itunesOwner: { name: 'John Doe', email: 'john@example.com' }, * itunesImage: 'https://example.com/artwork.jpg', * itunesExplicit: false * }); * ``` */ export class PodcastFeed extends Feed { public podcastOptions: IPodcastFeedOptions; public episodes: IPodcastItem[] = []; /** * Creates a new PodcastFeed instance * @param optionsArg - Podcast feed configuration options * @throws Error if validation fails */ constructor(optionsArg: IPodcastFeedOptions) { super(optionsArg); // Validate podcast-specific fields validation.validateRequiredFields( optionsArg, ['itunesCategory', 'itunesAuthor', 'itunesOwner', 'itunesImage'], 'Podcast feed options' ); // Validate iTunes owner validation.validateRequiredFields( optionsArg.itunesOwner, ['name', 'email'], 'iTunes owner' ); validation.validateEmail(optionsArg.itunesOwner.email); // Validate iTunes image URL validation.validateUrl(optionsArg.itunesImage, true); // Validate iTunes type if provided if (optionsArg.itunesType && !['episodic', 'serial'].includes(optionsArg.itunesType)) { 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; } /** * Adds an episode to the podcast feed * @param episodeArg - The podcast episode to add * @throws Error if validation fails * @example * ```typescript * podcast.addEpisode({ * title: 'Episode 1: Getting Started', * authorName: 'John Doe', * imageUrl: 'https://example.com/episode1.jpg', * timestamp: Date.now(), * url: 'https://example.com/episode/1', * content: 'In this episode we discuss getting started', * audioUrl: 'https://example.com/audio/episode1.mp3', * audioType: 'audio/mpeg', * audioLength: 45678900, * itunesDuration: 3600, * itunesEpisode: 1, * itunesSeason: 1 * }); * ``` */ public addEpisode(episodeArg: IPodcastItem): void { // Validate standard item fields first // Note: itunesDuration is validated separately to allow for proper numeric validation validation.validateRequiredFields( episodeArg, ['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content', 'audioUrl', 'audioType', 'audioLength'], 'Podcast episode' ); // Validate URLs validation.validateUrl(episodeArg.url, true); validation.validateUrl(episodeArg.imageUrl, true); validation.validateUrl(episodeArg.audioUrl, true); // Validate timestamp validation.validateTimestamp(episodeArg.timestamp); // Validate audio file type if (!episodeArg.audioType.startsWith('audio/')) { throw new Error(`Invalid audio type: ${episodeArg.audioType}. Must start with 'audio/'`); } // Validate audio length if (typeof episodeArg.audioLength !== 'number' || episodeArg.audioLength <= 0) { throw new Error('Audio length must be a positive number (bytes)'); } // Validate duration (must be provided and be a positive number) if (episodeArg.itunesDuration === undefined || episodeArg.itunesDuration === null) { throw new Error('iTunes duration is required'); } if (typeof episodeArg.itunesDuration !== 'number' || episodeArg.itunesDuration <= 0) { throw new Error('iTunes duration must be a positive number (seconds)'); } // Validate episode type if provided if (episodeArg.itunesEpisodeType && !['full', 'trailer', 'bonus'].includes(episodeArg.itunesEpisodeType)) { throw new Error('iTunes episode type must be "full", "trailer", or "bonus"'); } // Validate episode/season numbers if provided if (episodeArg.itunesEpisode !== undefined && (episodeArg.itunesEpisode < 1 || !Number.isInteger(episodeArg.itunesEpisode))) { throw new Error('iTunes episode number must be a positive integer'); } if (episodeArg.itunesSeason !== undefined && (episodeArg.itunesSeason < 1 || !Number.isInteger(episodeArg.itunesSeason))) { throw new Error('iTunes season number must be a positive integer'); } // Validate transcripts if provided if (episodeArg.transcripts) { for (const transcript of episodeArg.transcripts) { validation.validateUrl(transcript.url, true); if (!transcript.type) { throw new Error('Transcript type is required'); } } } // Validate funding links if provided if (episodeArg.funding) { for (const funding of episodeArg.funding) { validation.validateUrl(funding.url, true); if (!funding.message) { throw new Error('Funding message is required'); } } } // Validate ID uniqueness (use URL as ID if not provided) const itemId = episodeArg.id || episodeArg.url; if (this.itemIds.has(itemId)) { throw new Error( `Duplicate episode ID: ${itemId}. Each episode must have a unique ID or URL. ` + `IDs should never change once published.` ); } this.itemIds.add(itemId); this.episodes.push(episodeArg); this.items.push(episodeArg); // Also add to base items array } /** * Formats duration in HH:MM:SS format for iTunes * @param seconds - Duration in seconds * @returns Formatted duration string */ private formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } /** * Exports the podcast feed as RSS 2.0 with iTunes and Podcast namespaces * @returns RSS 2.0 XML string with podcast extensions */ public exportPodcastRss(): string { // Build RSS manually to include iTunes namespace let rss = '\n'; rss += '${this.escapeXml(this.podcastOptions.title)}\n`; rss += `https://${this.podcastOptions.domain}\n`; rss += `${this.escapeXml(this.podcastOptions.description)}\n`; rss += `${this.podcastOptions.language || 'en'}\n`; rss += `${this.escapeXml(this.podcastOptions.copyright || `All rights reserved, ${this.podcastOptions.company}`)}\n`; rss += `@push.rocks/smartfeed\n`; rss += `${new Date().toUTCString()}\n`; // Atom self link const selfUrl = this.podcastOptions.feedUrl || `https://${this.podcastOptions.domain}/feed.xml`; rss += `\n`; // iTunes channel tags rss += `${this.escapeXml(this.podcastOptions.itunesAuthor)}\n`; rss += `${this.escapeXml(this.podcastOptions.itunesSummary || this.podcastOptions.description)}\n`; rss += `${this.podcastOptions.itunesExplicit ? 'true' : 'false'}\n`; rss += `\n`; rss += `\n\n\n`; } else { rss += ' />\n'; } rss += `\n`; rss += `${this.escapeXml(this.podcastOptions.itunesOwner.name)}\n`; rss += `${this.podcastOptions.itunesOwner.email}\n`; rss += `\n`; if (this.podcastOptions.itunesType) { 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'; rss += `${this.escapeXml(episode.title)}\n`; rss += `${episode.url}\n`; rss += `${episode.id || episode.url}\n`; rss += `${new Date(episode.timestamp).toUTCString()}\n`; rss += `\n`; // Audio enclosure rss += `\n`; // iTunes episode tags rss += `${this.escapeXml(episode.title)}\n`; rss += `${this.escapeXml(episode.authorName)}\n`; rss += `${this.formatDuration(episode.itunesDuration)}\n`; rss += `${episode.itunesExplicit !== undefined ? (episode.itunesExplicit ? 'true' : 'false') : 'false'}\n`; if (episode.itunesSubtitle) { rss += `${this.escapeXml(episode.itunesSubtitle)}\n`; } if (episode.itunesSummary) { rss += `${this.escapeXml(episode.itunesSummary)}\n`; } if (episode.itunesEpisode !== undefined) { rss += `${episode.itunesEpisode}\n`; } if (episode.itunesSeason !== undefined) { rss += `${episode.itunesSeason}\n`; } if (episode.itunesEpisodeType) { rss += `${episode.itunesEpisodeType}\n`; } rss += `\n`; // Modern podcast namespace if (episode.persons && episode.persons.length > 0) { for (const person of episode.persons) { rss += `${this.escapeXml(person.name)}\n`; } } if (episode.transcripts && episode.transcripts.length > 0) { for (const transcript of episode.transcripts) { rss += ` 0) { for (const funding of episode.funding) { rss += `${this.escapeXml(funding.message)}\n`; } } rss += '\n'; } rss += '\n'; rss += ''; return rss; } }