488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
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 = '<?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
|
|
const selfUrl = this.podcastOptions.feedUrl || `https://${this.podcastOptions.domain}/feed.xml`;
|
|
rss += `<atom:link href="${selfUrl}" 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`;
|
|
}
|
|
|
|
// Podcast 2.0 namespace tags
|
|
rss += `<podcast:guid>${this.escapeXml(this.podcastOptions.podcastGuid)}</podcast:guid>\n`;
|
|
|
|
if (this.podcastOptions.podcastMedium) {
|
|
rss += `<podcast:medium>${this.podcastOptions.podcastMedium}</podcast:medium>\n`;
|
|
} else {
|
|
// Default to 'podcast' if not specified
|
|
rss += `<podcast:medium>podcast</podcast:medium>\n`;
|
|
}
|
|
|
|
if (this.podcastOptions.podcastLocked !== undefined) {
|
|
const lockedValue = this.podcastOptions.podcastLocked ? 'yes' : 'no';
|
|
if (this.podcastOptions.podcastLockOwner) {
|
|
rss += `<podcast:locked owner="${this.escapeXml(this.podcastOptions.podcastLockOwner)}">${lockedValue}</podcast:locked>\n`;
|
|
} else {
|
|
rss += `<podcast:locked>${lockedValue}</podcast:locked>\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;
|
|
}
|
|
}
|