Fix
This commit is contained in:
@ -3,7 +3,7 @@ import {
|
|||||||
openAI_ClassifyFeed,
|
openAI_ClassifyFeed,
|
||||||
openAI_GeneratePodcastContent,
|
openAI_GeneratePodcastContent,
|
||||||
} from "../services/llm.js";
|
} from "../services/llm.js";
|
||||||
import { generateTTS } from "../services/tts.js";
|
import { generateTTS, generateTTSWithoutQueue } from "../services/tts.js";
|
||||||
import {
|
import {
|
||||||
saveFeed,
|
saveFeed,
|
||||||
getFeedByUrl,
|
getFeedByUrl,
|
||||||
@ -208,15 +208,30 @@ async function processUnprocessedArticles(): Promise<void> {
|
|||||||
|
|
||||||
console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`);
|
console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`);
|
||||||
|
|
||||||
// Track articles that successfully generated audio
|
// Track articles that successfully generated audio AND episodes
|
||||||
const successfullyGeneratedArticles: string[] = [];
|
let successfullyGeneratedCount = 0;
|
||||||
|
|
||||||
for (const article of unprocessedArticles) {
|
for (const article of unprocessedArticles) {
|
||||||
try {
|
try {
|
||||||
await generatePodcastForArticle(article);
|
const episodeCreated = await generatePodcastForArticle(article);
|
||||||
await markArticleAsProcessed(article.id);
|
|
||||||
console.log(`✅ Podcast generated for: ${article.title}`);
|
// Only mark as processed and update RSS if episode was actually created
|
||||||
successfullyGeneratedArticles.push(article.id);
|
if (episodeCreated) {
|
||||||
|
await markArticleAsProcessed(article.id);
|
||||||
|
console.log(`✅ Podcast generated for: ${article.title}`);
|
||||||
|
successfullyGeneratedCount++;
|
||||||
|
|
||||||
|
// Update RSS immediately after each successful episode creation
|
||||||
|
console.log(`📻 Updating podcast RSS after successful episode creation...`);
|
||||||
|
try {
|
||||||
|
await updatePodcastRSS();
|
||||||
|
console.log(`📻 RSS updated successfully for: ${article.title}`);
|
||||||
|
} catch (rssError) {
|
||||||
|
console.error(`❌ Failed to update RSS after episode creation for: ${article.title}`, rssError);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ Episode creation failed for: ${article.title} - not marking as processed`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ Failed to generate podcast for article: ${article.title}`,
|
`❌ Failed to generate podcast for article: ${article.title}`,
|
||||||
@ -226,10 +241,9 @@ async function processUnprocessedArticles(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update RSS if at least one article was successfully processed
|
console.log(`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`);
|
||||||
if (successfullyGeneratedArticles.length > 0) {
|
if (successfullyGeneratedCount === 0) {
|
||||||
console.log(`📻 Updating podcast RSS for ${successfullyGeneratedArticles.length} new episodes...`);
|
console.log(`ℹ️ No episodes were successfully created in this batch`);
|
||||||
await updatePodcastRSS();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("💥 Error processing unprocessed articles:", error);
|
console.error("💥 Error processing unprocessed articles:", error);
|
||||||
@ -242,6 +256,8 @@ async function processUnprocessedArticles(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
async function processRetryQueue(): Promise<void> {
|
async function processRetryQueue(): Promise<void> {
|
||||||
const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js");
|
const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js");
|
||||||
|
const { Database } = await import("bun:sqlite");
|
||||||
|
const db = new Database(config.paths.dbPath);
|
||||||
|
|
||||||
console.log("🔄 Processing TTS retry queue...");
|
console.log("🔄 Processing TTS retry queue...");
|
||||||
|
|
||||||
@ -256,41 +272,65 @@ async function processRetryQueue(): Promise<void> {
|
|||||||
|
|
||||||
for (const item of queueItems) {
|
for (const item of queueItems) {
|
||||||
try {
|
try {
|
||||||
console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1})`);
|
console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`);
|
||||||
|
|
||||||
// Mark as processing
|
// Mark as processing
|
||||||
await updateQueueItemStatus(item.id, 'processing');
|
await updateQueueItemStatus(item.id, 'processing');
|
||||||
|
|
||||||
// Attempt TTS generation
|
// Attempt TTS generation without re-queuing on failure
|
||||||
await generateTTS(item.itemId, item.scriptText, item.retryCount);
|
const audioFilePath = await generateTTSWithoutQueue(item.itemId, item.scriptText, item.retryCount);
|
||||||
|
|
||||||
// Success - remove from queue
|
// Success - remove from queue and update RSS
|
||||||
await removeFromQueue(item.id);
|
await removeFromQueue(item.id);
|
||||||
console.log(`✅ TTS retry successful for: ${item.itemId}`);
|
console.log(`✅ TTS retry successful for: ${item.itemId}`);
|
||||||
|
|
||||||
|
// Update RSS immediately after successful retry
|
||||||
|
console.log(`📻 Updating podcast RSS after successful retry...`);
|
||||||
|
try {
|
||||||
|
await updatePodcastRSS();
|
||||||
|
console.log(`📻 RSS updated successfully after retry for: ${item.itemId}`);
|
||||||
|
} catch (rssError) {
|
||||||
|
console.error(`❌ Failed to update RSS after retry for: ${item.itemId}`, rssError);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
|
console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
|
||||||
|
|
||||||
if (item.retryCount >= 2) {
|
try {
|
||||||
// Max retries reached, mark as failed
|
if (item.retryCount >= 2) {
|
||||||
await updateQueueItemStatus(item.id, 'failed');
|
// Max retries reached, mark as failed
|
||||||
console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`);
|
await updateQueueItemStatus(item.id, 'failed');
|
||||||
} else {
|
console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`);
|
||||||
// Reset to pending for next retry
|
} else {
|
||||||
await updateQueueItemStatus(item.id, 'pending');
|
// Increment retry count and reset to pending for next retry
|
||||||
|
const updatedRetryCount = item.retryCount + 1;
|
||||||
|
const stmt = db.prepare("UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?");
|
||||||
|
stmt.run(updatedRetryCount, item.id);
|
||||||
|
console.log(`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`);
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error(`❌ Failed to update queue status for: ${item.itemId}`, dbError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("💥 Error processing retry queue:", error);
|
console.error("💥 Error processing retry queue:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// Clean up database connection
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn("⚠️ Warning: Failed to close database connection:", closeError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate podcast for a single article
|
* Generate podcast for a single article
|
||||||
|
* Returns true if episode was successfully created, false otherwise
|
||||||
*/
|
*/
|
||||||
async function generatePodcastForArticle(article: any): Promise<void> {
|
async function generatePodcastForArticle(article: any): Promise<boolean> {
|
||||||
console.log(`🎤 Generating podcast for: ${article.title}`);
|
console.log(`🎤 Generating podcast for: ${article.title}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -315,31 +355,66 @@ async function generatePodcastForArticle(article: any): Promise<void> {
|
|||||||
// Generate unique ID for the episode
|
// Generate unique ID for the episode
|
||||||
const episodeId = crypto.randomUUID();
|
const episodeId = crypto.randomUUID();
|
||||||
|
|
||||||
// Generate TTS audio
|
// Generate TTS audio - this is the critical step that can fail
|
||||||
const audioFilePath = await generateTTS(episodeId, podcastContent);
|
let audioFilePath: string;
|
||||||
console.log(`🔊 Audio generated: ${audioFilePath}`);
|
try {
|
||||||
|
audioFilePath = await generateTTS(episodeId, podcastContent);
|
||||||
|
console.log(`🔊 Audio generated: ${audioFilePath}`);
|
||||||
|
} catch (ttsError) {
|
||||||
|
console.error(`❌ TTS generation failed for ${article.title}:`, ttsError);
|
||||||
|
|
||||||
|
// Check if error indicates item was added to retry queue
|
||||||
|
const errorMessage = ttsError instanceof Error ? ttsError.message : String(ttsError);
|
||||||
|
if (errorMessage.includes('added to retry queue')) {
|
||||||
|
console.log(`📋 Article will be retried later via TTS queue: ${article.title}`);
|
||||||
|
// Don't mark as processed - leave it for retry
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
console.error(`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`);
|
||||||
|
// Max retries exceeded, don't create episode but mark as processed to avoid infinite retry
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify audio file was actually created and is valid
|
||||||
|
try {
|
||||||
|
const audioStats = await getAudioFileStats(audioFilePath);
|
||||||
|
if (audioStats.size === 0) {
|
||||||
|
console.error(`❌ Audio file is empty for ${article.title}: ${audioFilePath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (statsError) {
|
||||||
|
console.error(`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`, statsError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Get audio file stats
|
// Get audio file stats
|
||||||
const audioStats = await getAudioFileStats(audioFilePath);
|
const audioStats = await getAudioFileStats(audioFilePath);
|
||||||
|
|
||||||
// Save episode
|
// Save episode - only if we have valid audio
|
||||||
await saveEpisode({
|
try {
|
||||||
articleId: article.id,
|
await saveEpisode({
|
||||||
title: `${category}: ${article.title}`,
|
articleId: article.id,
|
||||||
description:
|
title: `${category}: ${article.title}`,
|
||||||
article.description || `Podcast episode for: ${article.title}`,
|
description:
|
||||||
audioPath: audioFilePath,
|
article.description || `Podcast episode for: ${article.title}`,
|
||||||
duration: audioStats.duration,
|
audioPath: audioFilePath,
|
||||||
fileSize: audioStats.size,
|
duration: audioStats.duration,
|
||||||
});
|
fileSize: audioStats.size,
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`💾 Episode saved for article: ${article.title}`);
|
console.log(`💾 Episode saved for article: ${article.title}`);
|
||||||
|
return true;
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error(`❌ Failed to save episode for ${article.title}:`, saveError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`💥 Error generating podcast for article: ${article.title}`,
|
`💥 Error generating podcast for article: ${article.title}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
throw error;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
195
services/tts.ts
195
services/tts.ts
@ -12,7 +12,11 @@ const defaultVoiceStyle: VoiceStyle = {
|
|||||||
styleId: config.voicevox.styleId,
|
styleId: config.voicevox.styleId,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateTTS(
|
/**
|
||||||
|
* Generate TTS without adding to retry queue on failure
|
||||||
|
* Used for retry queue processing to avoid infinite loops
|
||||||
|
*/
|
||||||
|
export async function generateTTSWithoutQueue(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
scriptText: string,
|
scriptText: string,
|
||||||
retryCount: number = 0,
|
retryCount: number = 0,
|
||||||
@ -25,104 +29,113 @@ export async function generateTTS(
|
|||||||
throw new Error("Script text is required for TTS generation");
|
throw new Error("Script text is required for TTS generation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 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 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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTTS(
|
||||||
|
itemId: string,
|
||||||
|
scriptText: string,
|
||||||
|
retryCount: number = 0,
|
||||||
|
): Promise<string> {
|
||||||
const maxRetries = 2;
|
const maxRetries = 2;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}/${maxRetries + 1})`);
|
return await generateTTSWithoutQueue(itemId, scriptText, retryCount);
|
||||||
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 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}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
|
console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
|
||||||
|
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
|
// Add to queue for retry only on initial failure
|
||||||
const { addToQueue } = await import("../services/database.js");
|
const { addToQueue } = await import("../services/database.js");
|
||||||
await addToQueue(itemId, scriptText, retryCount + 1);
|
await addToQueue(itemId, scriptText, retryCount);
|
||||||
throw new Error(`TTS generation failed, added to retry queue: ${error}`);
|
throw new Error(`TTS generation failed, added to retry queue: ${error}`);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
|
throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
|
||||||
|
Reference in New Issue
Block a user