Files
VoiceRSSSummary/services/podcast.ts

346 lines
11 KiB
TypeScript

import { promises as fs } from "fs";
import fsSync from "node:fs";
import path from "node:path";
import { dirname } from "path";
import { config } from "./config.js";
import {
fetchEpisodesByFeedId,
fetchEpisodesWithFeedInfo,
getEpisodesByCategory,
} from "./database.js";
function escapeXml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function createItemXml(episode: any): string {
const fileUrl = `${config.podcast.baseUrl}/podcast_audio/${path.basename(episode.audioPath)}`;
const pubDate = new Date(episode.createdAt).toUTCString();
let fileSize = 0;
try {
const audioPath = path.join(
config.paths.podcastAudioDir,
episode.audioPath,
);
if (fsSync.existsSync(audioPath)) {
fileSize = fsSync.statSync(audioPath).size;
}
} catch (error) {
console.warn(`Could not get file size for ${episode.audioPath}:`, error);
}
// Build enhanced description with feed and article info
let description = episode.title;
if (episode.feedTitle || episode.articleTitle || episode.articleLink) {
description += "\n\n";
if (episode.feedTitle) {
description += `フィード: ${episode.feedTitle}\n`;
}
if (episode.articleTitle && episode.articleTitle !== episode.title) {
description += `元記事: ${episode.articleTitle}\n`;
}
if (episode.articlePubDate) {
description += `記事公開日: ${new Date(episode.articlePubDate).toLocaleString("ja-JP")}\n`;
}
if (episode.articleLink) {
description += `元記事URL: ${episode.articleLink}`;
}
}
return `
<item>
<title><![CDATA[${escapeXml(episode.title)}]]></title>
<description><![CDATA[${escapeXml(description)}]]></description>
<author>${escapeXml(config.podcast.author)}</author>
<category>${escapeXml(config.podcast.categories)}</category>
<language>${config.podcast.language}</language>
<ttl>${config.podcast.ttl}</ttl>
<enclosure url="${escapeXml(fileUrl)}" length="${fileSize}" type="audio/mpeg" />
<guid>${escapeXml(fileUrl)}</guid>
<pubDate>${pubDate}</pubDate>
${episode.articleLink ? `<link>${escapeXml(episode.articleLink)}</link>` : ""}
</item>`;
}
// Filter episodes to only include those with valid audio files
function filterValidEpisodes(episodes: any[]): any[] {
return episodes.filter((episode) => {
try {
const audioPath = path.join(
config.paths.podcastAudioDir,
episode.audioPath,
);
return fsSync.existsSync(audioPath);
} catch (error) {
console.warn(`Audio file not found for episode: ${episode.title}`);
return false;
}
});
}
// Generate RSS XML from episodes
function generateRSSXml(
episodes: any[],
title: string,
description: string,
link?: string,
): string {
const lastBuildDate = new Date().toUTCString();
const itemsXml = episodes.map(createItemXml).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${escapeXml(title)}</title>
<link>${escapeXml(link || config.podcast.link)}</link>
<description><![CDATA[${escapeXml(description)}]]></description>
<language>${config.podcast.language}</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>${config.podcast.ttl}</ttl>
<author>${escapeXml(config.podcast.author)}</author>
<category>${escapeXml(config.podcast.categories)}</category>${itemsXml}
</channel>
</rss>`;
}
export async function updatePodcastRSS(): Promise<void> {
try {
// Use episodes with feed info for enhanced descriptions
const episodesWithFeedInfo = await fetchEpisodesWithFeedInfo();
const validEpisodes = filterValidEpisodes(episodesWithFeedInfo);
console.log(
`Found ${episodesWithFeedInfo.length} episodes, ${validEpisodes.length} with valid audio files`,
);
const outputPath = path.join(config.paths.publicDir, "podcast.xml");
const rssXml = generateRSSXml(
validEpisodes,
config.podcast.title,
config.podcast.description,
);
// Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, rssXml);
console.log(
`RSS feed updated with ${validEpisodes.length} episodes (audio files verified)`,
);
} catch (error) {
console.error("Error updating podcast RSS:", error);
throw error;
}
}
/**
* Generate all category RSS files as static files
*/
export async function generateAllCategoryRSSFiles(): Promise<void> {
try {
const { getAllEpisodeCategories } = await import("./database.js");
const categories = await getAllEpisodeCategories();
console.log(`🔄 Generating ${categories.length} category RSS files...`);
for (const category of categories) {
try {
await saveCategoryRSSFile(category);
} catch (error) {
console.error(`❌ Failed to generate RSS for category "${category}":`, error);
}
}
console.log(`✅ Generated category RSS files for ${categories.length} categories`);
} catch (error) {
console.error("❌ Error generating category RSS files:", error);
throw error;
}
}
/**
* Generate all feed RSS files as static files
*/
export async function generateAllFeedRSSFiles(): Promise<void> {
try {
const { fetchActiveFeeds } = await import("./database.js");
const feeds = await fetchActiveFeeds();
console.log(`🔄 Generating ${feeds.length} feed RSS files...`);
for (const feed of feeds) {
try {
await saveFeedRSSFile(feed.id);
} catch (error) {
console.error(`❌ Failed to generate RSS for feed "${feed.id}":`, error);
}
}
console.log(`✅ Generated feed RSS files for ${feeds.length} feeds`);
} catch (error) {
console.error("❌ Error generating feed RSS files:", error);
throw error;
}
}
/**
* Save category RSS as static file with URL-safe filename
*/
export async function saveCategoryRSSFile(category: string): Promise<void> {
try {
const rssXml = await generateCategoryRSS(category);
const safeCategory = encodeURIComponent(category);
const outputPath = path.join(
config.paths.publicDir,
`podcast_category_${safeCategory}.xml`,
);
// Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, rssXml);
console.log(`📄 Category RSS saved: podcast_category_${safeCategory}.xml`);
} catch (error) {
console.error(`❌ Error saving category RSS for "${category}":`, error);
throw error;
}
}
/**
* Save feed RSS as static file
*/
export async function saveFeedRSSFile(feedId: string): Promise<void> {
try {
const rssXml = await generateFeedRSS(feedId);
const outputPath = path.join(
config.paths.publicDir,
`podcast_feed_${feedId}.xml`,
);
// Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, rssXml);
console.log(`📄 Feed RSS saved: podcast_feed_${feedId}.xml`);
} catch (error) {
console.error(`❌ Error saving feed RSS for "${feedId}":`, error);
throw error;
}
}
/**
* Regenerate all static files on startup
* This ensures that podcast.xml and other generated files are up-to-date
*/
export async function regenerateStartupFiles(): Promise<void> {
try {
console.log("🔄 Regenerating all static files on startup...");
// Regenerate main podcast.xml
await updatePodcastRSS();
console.log("✅ podcast.xml regenerated successfully");
// Generate all category RSS files
await generateAllCategoryRSSFiles();
// Generate all feed RSS files
await generateAllFeedRSSFiles();
console.log("✅ All startup files regenerated successfully");
} catch (error) {
console.error("❌ Error regenerating startup files:", error);
throw error;
}
}
export async function generateCategoryRSS(category: string): Promise<string> {
try {
// Get episodes for the specific category
const episodesWithFeedInfo = await getEpisodesByCategory(category);
const validEpisodes = filterValidEpisodes(episodesWithFeedInfo);
console.log(
`Found ${episodesWithFeedInfo.length} episodes for category "${category}", ${validEpisodes.length} with valid audio files`,
);
const title = `${config.podcast.title} - ${category}`;
const description = `${config.podcast.description} カテゴリ: ${category}`;
return generateRSSXml(validEpisodes, title, description);
} catch (error) {
console.error(`Error generating category RSS for "${category}":`, error);
throw error;
}
}
export async function saveCategoryRSS(category: string): Promise<void> {
try {
const rssXml = await generateCategoryRSS(category);
const safeCategory = category.replace(
/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g,
"_",
);
const outputPath = path.join(
config.paths.publicDir,
`podcast_category_${safeCategory}.xml`,
);
// Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, rssXml);
console.log(`Category RSS saved for "${category}" at ${outputPath}`);
} catch (error) {
console.error(`Error saving category RSS for "${category}":`, error);
throw error;
}
}
export async function generateFeedRSS(feedId: string): Promise<string> {
try {
// Get episodes for the specific feed
const episodesWithFeedInfo = await fetchEpisodesByFeedId(feedId);
const validEpisodes = filterValidEpisodes(episodesWithFeedInfo);
console.log(
`Found ${episodesWithFeedInfo.length} episodes for feed "${feedId}", ${validEpisodes.length} with valid audio files`,
);
// Use feed info for RSS metadata if available
const feedTitle =
validEpisodes.length > 0 ? validEpisodes[0].feedTitle : "Unknown Feed";
const title = `${config.podcast.title} - ${feedTitle}`;
const description = `${config.podcast.description} フィード: ${feedTitle}`;
return generateRSSXml(validEpisodes, title, description);
} catch (error) {
console.error(`Error generating feed RSS for "${feedId}":`, error);
throw error;
}
}
export async function saveFeedRSS(feedId: string): Promise<void> {
try {
const rssXml = await generateFeedRSS(feedId);
const outputPath = path.join(
config.paths.publicDir,
`podcast_feed_${feedId}.xml`,
);
// Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, rssXml);
console.log(`Feed RSS saved for feed "${feedId}" at ${outputPath}`);
} catch (error) {
console.error(`Error saving feed RSS for "${feedId}":`, error);
throw error;
}
}