346 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			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, "<")
 | 
						|
    .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 `
 | 
						|
    <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;
 | 
						|
  }
 | 
						|
}
 |