Files
smartfeed/ts/classes.podcast.ts

437 lines
15 KiB
TypeScript

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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
rss += '<rss version="2.0" ';
rss += 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" ';
rss += 'xmlns:podcast="https://podcastindex.org/namespace/1.0" ';
rss += 'xmlns:atom="http://www.w3.org/2005/Atom">\n';
rss += '<channel>\n';
// Standard RSS fields
rss += `<title>${this.escapeXml(this.podcastOptions.title)}</title>\n`;
rss += `<link>https://${this.podcastOptions.domain}</link>\n`;
rss += `<description>${this.escapeXml(this.podcastOptions.description)}</description>\n`;
rss += `<language>${this.podcastOptions.language || 'en'}</language>\n`;
rss += `<copyright>${this.escapeXml(this.podcastOptions.copyright || `All rights reserved, ${this.podcastOptions.company}`)}</copyright>\n`;
rss += `<generator>@push.rocks/smartfeed</generator>\n`;
rss += `<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>\n`;
// Atom self link
rss += `<atom:link href="https://${this.podcastOptions.domain}/feed.xml" rel="self" type="application/rss+xml" />\n`;
// iTunes channel tags
rss += `<itunes:author>${this.escapeXml(this.podcastOptions.itunesAuthor)}</itunes:author>\n`;
rss += `<itunes:summary>${this.escapeXml(this.podcastOptions.itunesSummary || this.podcastOptions.description)}</itunes:summary>\n`;
rss += `<itunes:explicit>${this.podcastOptions.itunesExplicit ? 'true' : 'false'}</itunes:explicit>\n`;
rss += `<itunes:image href="${this.podcastOptions.itunesImage}" />\n`;
rss += `<itunes:category text="${this.escapeXml(this.podcastOptions.itunesCategory)}"`;
if (this.podcastOptions.itunesSubcategory) {
rss += `>\n<itunes:category text="${this.escapeXml(this.podcastOptions.itunesSubcategory)}" />\n</itunes:category>\n`;
} else {
rss += ' />\n';
}
rss += `<itunes:owner>\n`;
rss += `<itunes:name>${this.escapeXml(this.podcastOptions.itunesOwner.name)}</itunes:name>\n`;
rss += `<itunes:email>${this.podcastOptions.itunesOwner.email}</itunes:email>\n`;
rss += `</itunes:owner>\n`;
if (this.podcastOptions.itunesType) {
rss += `<itunes:type>${this.podcastOptions.itunesType}</itunes:type>\n`;
}
// Episodes
for (const episode of this.episodes) {
rss += '<item>\n';
rss += `<title>${this.escapeXml(episode.title)}</title>\n`;
rss += `<link>${episode.url}</link>\n`;
rss += `<guid isPermaLink="false">${episode.id || episode.url}</guid>\n`;
rss += `<pubDate>${new Date(episode.timestamp).toUTCString()}</pubDate>\n`;
rss += `<description><![CDATA[${episode.content}]]></description>\n`;
// Audio enclosure
rss += `<enclosure url="${episode.audioUrl}" length="${episode.audioLength}" type="${episode.audioType}" />\n`;
// iTunes episode tags
rss += `<itunes:title>${this.escapeXml(episode.title)}</itunes:title>\n`;
rss += `<itunes:author>${this.escapeXml(episode.authorName)}</itunes:author>\n`;
rss += `<itunes:duration>${this.formatDuration(episode.itunesDuration)}</itunes:duration>\n`;
rss += `<itunes:explicit>${episode.itunesExplicit !== undefined ? (episode.itunesExplicit ? 'true' : 'false') : 'false'}</itunes:explicit>\n`;
if (episode.itunesSubtitle) {
rss += `<itunes:subtitle>${this.escapeXml(episode.itunesSubtitle)}</itunes:subtitle>\n`;
}
if (episode.itunesSummary) {
rss += `<itunes:summary>${this.escapeXml(episode.itunesSummary)}</itunes:summary>\n`;
}
if (episode.itunesEpisode !== undefined) {
rss += `<itunes:episode>${episode.itunesEpisode}</itunes:episode>\n`;
}
if (episode.itunesSeason !== undefined) {
rss += `<itunes:season>${episode.itunesSeason}</itunes:season>\n`;
}
if (episode.itunesEpisodeType) {
rss += `<itunes:episodeType>${episode.itunesEpisodeType}</itunes:episodeType>\n`;
}
rss += `<itunes:image href="${episode.imageUrl}" />\n`;
// Modern podcast namespace
if (episode.persons && episode.persons.length > 0) {
for (const person of episode.persons) {
rss += `<podcast:person role="${person.role || 'guest'}"`;
if (person.href) rss += ` href="${person.href}"`;
if (person.img) rss += ` img="${person.img}"`;
rss += `>${this.escapeXml(person.name)}</podcast:person>\n`;
}
}
if (episode.transcripts && episode.transcripts.length > 0) {
for (const transcript of episode.transcripts) {
rss += `<podcast:transcript url="${transcript.url}" type="${transcript.type}"`;
if (transcript.language) rss += ` language="${transcript.language}"`;
if (transcript.rel) rss += ` rel="${transcript.rel}"`;
rss += ' />\n';
}
}
if (episode.funding && episode.funding.length > 0) {
for (const funding of episode.funding) {
rss += `<podcast:funding url="${funding.url}">${this.escapeXml(funding.message)}</podcast:funding>\n`;
}
}
rss += '</item>\n';
}
rss += '</channel>\n';
rss += '</rss>';
return rss;
}
/**
* Escapes XML special characters
* @param str - String to escape
* @returns Escaped string
*/
private escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}