import * as plugins from './plugins.js'; import * as validation from './validation.js'; import { Feed, 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 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; } /** * 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"'); } 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 validation.validateRequiredFields( episodeArg, ['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content', 'audioUrl', 'audioType', 'audioLength', 'itunesDuration'], '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 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 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`; } // 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; } /** * 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, '''); } }