Update
This commit is contained in:
		@@ -60,11 +60,23 @@ function initializeDatabase(): Database {
 | 
			
		||||
    PRIMARY KEY(feed_url, item_id)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  CREATE TABLE IF NOT EXISTS tts_queue (
 | 
			
		||||
    id TEXT PRIMARY KEY,
 | 
			
		||||
    item_id TEXT NOT NULL,
 | 
			
		||||
    script_text TEXT NOT NULL,
 | 
			
		||||
    retry_count INTEGER DEFAULT 0,
 | 
			
		||||
    created_at TEXT NOT NULL,
 | 
			
		||||
    last_attempted_at TEXT,
 | 
			
		||||
    status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'failed'))
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);`);
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
 | 
			
		||||
  CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);`);
 | 
			
		||||
 | 
			
		||||
  return db;
 | 
			
		||||
}
 | 
			
		||||
@@ -506,6 +518,89 @@ export async function fetchEpisodesWithArticles(): Promise<
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TTS Queue management functions
 | 
			
		||||
export interface TTSQueueItem {
 | 
			
		||||
  id: string;
 | 
			
		||||
  itemId: string;
 | 
			
		||||
  scriptText: string;
 | 
			
		||||
  retryCount: number;
 | 
			
		||||
  createdAt: string;
 | 
			
		||||
  lastAttemptedAt?: string;
 | 
			
		||||
  status: 'pending' | 'processing' | 'failed';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function addToQueue(
 | 
			
		||||
  itemId: string,
 | 
			
		||||
  scriptText: string,
 | 
			
		||||
  retryCount: number = 0,
 | 
			
		||||
): Promise<string> {
 | 
			
		||||
  const id = crypto.randomUUID();
 | 
			
		||||
  const createdAt = new Date().toISOString();
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const stmt = db.prepare(
 | 
			
		||||
      "INSERT INTO tts_queue (id, item_id, script_text, retry_count, created_at, status) VALUES (?, ?, ?, ?, ?, 'pending')",
 | 
			
		||||
    );
 | 
			
		||||
    stmt.run(id, itemId, scriptText, retryCount, createdAt);
 | 
			
		||||
    console.log(`TTS queue に追加: ${itemId} (試行回数: ${retryCount})`);
 | 
			
		||||
    return id;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error adding to TTS queue:", error);
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getQueueItems(limit: number = 10): Promise<TTSQueueItem[]> {
 | 
			
		||||
  try {
 | 
			
		||||
    const stmt = db.prepare(`
 | 
			
		||||
      SELECT * FROM tts_queue
 | 
			
		||||
      WHERE status = 'pending'
 | 
			
		||||
      ORDER BY created_at ASC
 | 
			
		||||
      LIMIT ?
 | 
			
		||||
    `);
 | 
			
		||||
    const rows = stmt.all(limit) as any[];
 | 
			
		||||
 | 
			
		||||
    return rows.map((row) => ({
 | 
			
		||||
      id: row.id,
 | 
			
		||||
      itemId: row.item_id,
 | 
			
		||||
      scriptText: row.script_text,
 | 
			
		||||
      retryCount: row.retry_count,
 | 
			
		||||
      createdAt: row.created_at,
 | 
			
		||||
      lastAttemptedAt: row.last_attempted_at,
 | 
			
		||||
      status: row.status,
 | 
			
		||||
    }));
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error getting queue items:", error);
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function updateQueueItemStatus(
 | 
			
		||||
  queueId: string,
 | 
			
		||||
  status: 'pending' | 'processing' | 'failed',
 | 
			
		||||
  lastAttemptedAt?: string,
 | 
			
		||||
): Promise<void> {
 | 
			
		||||
  try {
 | 
			
		||||
    const stmt = db.prepare(
 | 
			
		||||
      "UPDATE tts_queue SET status = ?, last_attempted_at = ? WHERE id = ?",
 | 
			
		||||
    );
 | 
			
		||||
    stmt.run(status, lastAttemptedAt || new Date().toISOString(), queueId);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error updating queue item status:", error);
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function removeFromQueue(queueId: string): Promise<void> {
 | 
			
		||||
  try {
 | 
			
		||||
    const stmt = db.prepare("DELETE FROM tts_queue WHERE id = ?");
 | 
			
		||||
    stmt.run(queueId);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error removing from queue:", error);
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function closeDatabase(): void {
 | 
			
		||||
  db.close();
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -45,9 +45,22 @@ function createItemXml(episode: Episode): string {
 | 
			
		||||
export async function updatePodcastRSS(): Promise<void> {
 | 
			
		||||
  try {
 | 
			
		||||
    const episodes: Episode[] = await fetchAllEpisodes();
 | 
			
		||||
    const lastBuildDate = new Date().toUTCString();
 | 
			
		||||
    
 | 
			
		||||
    // Filter episodes to only include those with valid audio files
 | 
			
		||||
    const validEpisodes = 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;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const itemsXml = episodes.map(createItemXml).join("\n");
 | 
			
		||||
    console.log(`Found ${episodes.length} episodes, ${validEpisodes.length} with valid audio files`);
 | 
			
		||||
 | 
			
		||||
    const lastBuildDate = new Date().toUTCString();
 | 
			
		||||
    const itemsXml = validEpisodes.map(createItemXml).join("\n");
 | 
			
		||||
    const outputPath = path.join(config.paths.publicDir, "podcast.xml");
 | 
			
		||||
 | 
			
		||||
    // Create RSS XML content
 | 
			
		||||
@@ -69,7 +82,7 @@ export async function updatePodcastRSS(): Promise<void> {
 | 
			
		||||
    await fs.mkdir(dirname(outputPath), { recursive: true });
 | 
			
		||||
    await fs.writeFile(outputPath, rssXml);
 | 
			
		||||
    
 | 
			
		||||
    console.log(`RSS feed updated with ${episodes.length} episodes`);
 | 
			
		||||
    console.log(`RSS feed updated with ${validEpisodes.length} episodes (audio files verified)`);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error updating podcast RSS:", error);
 | 
			
		||||
    throw error;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										185
									
								
								services/tts.ts
									
									
									
									
									
								
							
							
						
						
									
										185
									
								
								services/tts.ts
									
									
									
									
									
								
							@@ -15,6 +15,7 @@ const defaultVoiceStyle: VoiceStyle = {
 | 
			
		||||
export async function generateTTS(
 | 
			
		||||
  itemId: string,
 | 
			
		||||
  scriptText: string,
 | 
			
		||||
  retryCount: number = 0,
 | 
			
		||||
): Promise<string> {
 | 
			
		||||
  if (!itemId || itemId.trim() === "") {
 | 
			
		||||
    throw new Error("Item ID is required for TTS generation");
 | 
			
		||||
@@ -24,93 +25,107 @@ export async function generateTTS(
 | 
			
		||||
    throw new Error("Script text is required for TTS generation");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log(`TTS生成開始: ${itemId}`);
 | 
			
		||||
  const encodedText = encodeURIComponent(scriptText);
 | 
			
		||||
  const maxRetries = 2;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}/${maxRetries + 1})`);
 | 
			
		||||
    const encodedText = encodeURIComponent(scriptText);
 | 
			
		||||
 | 
			
		||||
  const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
 | 
			
		||||
  const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
 | 
			
		||||
    const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
 | 
			
		||||
    const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
 | 
			
		||||
 | 
			
		||||
  const queryResponse = await fetch(queryUrl, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: {
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
      Accept: "application/json",
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
    const queryResponse = await fetch(queryUrl, {
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: {
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
        Accept: "application/json",
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  if (!queryResponse.ok) {
 | 
			
		||||
    const errorText = await queryResponse.text();
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      `VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`,
 | 
			
		||||
    );
 | 
			
		||||
    if (!queryResponse.ok) {
 | 
			
		||||
      const errorText = await queryResponse.text();
 | 
			
		||||
      throw new Error(
 | 
			
		||||
        `VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const audioQuery = await queryResponse.json();
 | 
			
		||||
 | 
			
		||||
    console.log(`音声合成開始: ${itemId}`);
 | 
			
		||||
    const audioResponse = await fetch(synthesisUrl, {
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: {
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify(audioQuery),
 | 
			
		||||
      signal: AbortSignal.timeout(600000), // 10分のタイムアウト
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!audioResponse.ok) {
 | 
			
		||||
      const errorText = await audioResponse.text();
 | 
			
		||||
      console.error(`音声合成失敗: ${itemId}`);
 | 
			
		||||
      throw new Error(
 | 
			
		||||
        `VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const audioArrayBuffer = await audioResponse.arrayBuffer();
 | 
			
		||||
    const audioBuffer = Buffer.from(audioArrayBuffer);
 | 
			
		||||
 | 
			
		||||
    // 出力ディレクトリの準備
 | 
			
		||||
    const outputDir = config.paths.podcastAudioDir;
 | 
			
		||||
    if (!fs.existsSync(outputDir)) {
 | 
			
		||||
      fs.mkdirSync(outputDir, { recursive: true });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
 | 
			
		||||
    const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
 | 
			
		||||
 | 
			
		||||
    console.log(`WAVファイル保存開始: ${wavFilePath}`);
 | 
			
		||||
    fs.writeFileSync(wavFilePath, audioBuffer);
 | 
			
		||||
    console.log(`WAVファイル保存完了: ${wavFilePath}`);
 | 
			
		||||
 | 
			
		||||
    console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
 | 
			
		||||
 | 
			
		||||
    const ffmpegCmd = ffmpegPath || "ffmpeg";
 | 
			
		||||
    const result = Bun.spawnSync({
 | 
			
		||||
      cmd: [
 | 
			
		||||
        ffmpegCmd,
 | 
			
		||||
        "-i",
 | 
			
		||||
        wavFilePath,
 | 
			
		||||
        "-codec:a",
 | 
			
		||||
        "libmp3lame",
 | 
			
		||||
        "-qscale:a",
 | 
			
		||||
        "2",
 | 
			
		||||
        "-y", // Overwrite output file
 | 
			
		||||
        mp3FilePath,
 | 
			
		||||
      ],
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (result.exitCode !== 0) {
 | 
			
		||||
      const stderr = result.stderr
 | 
			
		||||
        ? new TextDecoder().decode(result.stderr)
 | 
			
		||||
        : "Unknown error";
 | 
			
		||||
      throw new Error(`FFmpeg conversion failed: ${stderr}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Wavファイルを削除
 | 
			
		||||
    if (fs.existsSync(wavFilePath)) {
 | 
			
		||||
      fs.unlinkSync(wavFilePath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log(`TTS生成完了: ${itemId}`);
 | 
			
		||||
 | 
			
		||||
    return path.basename(mp3FilePath);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
 | 
			
		||||
    
 | 
			
		||||
    if (retryCount < maxRetries) {
 | 
			
		||||
      const { addToQueue } = await import("../services/database.js");
 | 
			
		||||
      await addToQueue(itemId, scriptText, retryCount + 1);
 | 
			
		||||
      throw new Error(`TTS generation failed, added to retry queue: ${error}`);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const audioQuery = await queryResponse.json();
 | 
			
		||||
 | 
			
		||||
  console.log(`音声合成開始: ${itemId}`);
 | 
			
		||||
  const audioResponse = await fetch(synthesisUrl, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: {
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
    },
 | 
			
		||||
    body: JSON.stringify(audioQuery),
 | 
			
		||||
    signal: AbortSignal.timeout(600000), // 10分のタイムアウト
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!audioResponse.ok) {
 | 
			
		||||
    const errorText = await audioResponse.text();
 | 
			
		||||
    console.error(`音声合成失敗: ${itemId}`);
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      `VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const audioArrayBuffer = await audioResponse.arrayBuffer();
 | 
			
		||||
  const audioBuffer = Buffer.from(audioArrayBuffer);
 | 
			
		||||
 | 
			
		||||
  // 出力ディレクトリの準備
 | 
			
		||||
  const outputDir = config.paths.podcastAudioDir;
 | 
			
		||||
  if (!fs.existsSync(outputDir)) {
 | 
			
		||||
    fs.mkdirSync(outputDir, { recursive: true });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
 | 
			
		||||
  const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
 | 
			
		||||
 | 
			
		||||
  console.log(`WAVファイル保存開始: ${wavFilePath}`);
 | 
			
		||||
  fs.writeFileSync(wavFilePath, audioBuffer);
 | 
			
		||||
  console.log(`WAVファイル保存完了: ${wavFilePath}`);
 | 
			
		||||
 | 
			
		||||
  console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
 | 
			
		||||
 | 
			
		||||
  const ffmpegCmd = ffmpegPath || "ffmpeg";
 | 
			
		||||
  const result = Bun.spawnSync({
 | 
			
		||||
    cmd: [
 | 
			
		||||
      ffmpegCmd,
 | 
			
		||||
      "-i",
 | 
			
		||||
      wavFilePath,
 | 
			
		||||
      "-codec:a",
 | 
			
		||||
      "libmp3lame",
 | 
			
		||||
      "-qscale:a",
 | 
			
		||||
      "2",
 | 
			
		||||
      "-y", // Overwrite output file
 | 
			
		||||
      mp3FilePath,
 | 
			
		||||
    ],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (result.exitCode !== 0) {
 | 
			
		||||
    const stderr = result.stderr
 | 
			
		||||
      ? new TextDecoder().decode(result.stderr)
 | 
			
		||||
      : "Unknown error";
 | 
			
		||||
    throw new Error(`FFmpeg conversion failed: ${stderr}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Wavファイルを削除
 | 
			
		||||
  if (fs.existsSync(wavFilePath)) {
 | 
			
		||||
    fs.unlinkSync(wavFilePath);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log(`TTS生成完了: ${itemId}`);
 | 
			
		||||
 | 
			
		||||
  return path.basename(mp3FilePath);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user