feat(podcast): Add Podcast 2.0 support and remove external feed dependency; implement internal RSS/Atom/JSON generators and update tests/README

This commit is contained in:
2025-10-31 21:04:50 +00:00
parent 90eb13ee17
commit 31c4460b34
16 changed files with 1667 additions and 81 deletions

View File

@@ -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 += `<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';
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}