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, ">") .replace(/"/g, """) .replace(/'/g, "'"); } 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 ` <![CDATA[${escapeXml(episode.title)}]]> ${escapeXml(config.podcast.author)} ${escapeXml(config.podcast.categories)} ${config.podcast.language} ${config.podcast.ttl} ${escapeXml(fileUrl)} ${pubDate} ${episode.articleLink ? `${escapeXml(episode.articleLink)}` : ""} `; } // 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 ` ${escapeXml(title)} ${escapeXml(link || config.podcast.link)} ${config.podcast.language} ${lastBuildDate} ${config.podcast.ttl} ${escapeXml(config.podcast.author)} ${escapeXml(config.podcast.categories)}${itemsXml} `; } export async function updatePodcastRSS(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; } }