Add new batch scheduler service
This commit is contained in:
		@@ -20,6 +20,7 @@ interface Stats {
 | 
				
			|||||||
  batchScheduler: {
 | 
					  batchScheduler: {
 | 
				
			||||||
    enabled: boolean;
 | 
					    enabled: boolean;
 | 
				
			||||||
    isRunning: boolean;
 | 
					    isRunning: boolean;
 | 
				
			||||||
 | 
					    canForceStop: boolean;
 | 
				
			||||||
    lastRun?: string;
 | 
					    lastRun?: string;
 | 
				
			||||||
    nextRun?: string;
 | 
					    nextRun?: string;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
@@ -169,6 +170,34 @@ function App() {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const forceStopBatch = async () => {
 | 
				
			||||||
 | 
					    if (!confirm('実行中のバッチ処理を強制停止しますか?\n\n進行中の処理が中断され、データの整合性に影響が出る可能性があります。')) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const res = await fetch('/api/admin/batch/force-stop', {
 | 
				
			||||||
 | 
					        method: 'POST'
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await res.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (res.ok) {
 | 
				
			||||||
 | 
					        if (data.result === 'STOPPED') {
 | 
				
			||||||
 | 
					          setSuccess(data.message);
 | 
				
			||||||
 | 
					        } else if (data.result === 'NO_PROCESS') {
 | 
				
			||||||
 | 
					          setSuccess(data.message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        loadData(); // Refresh data to update batch status
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        setError(data.error || 'バッチ処理強制停止に失敗しました');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError('バッチ処理強制停止に失敗しました');
 | 
				
			||||||
 | 
					      console.error('Error force stopping batch:', err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const toggleBatchScheduler = async (enable: boolean) => {
 | 
					  const toggleBatchScheduler = async (enable: boolean) => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const res = await fetch(`/api/admin/batch/${enable ? 'enable' : 'disable'}`, {
 | 
					      const res = await fetch(`/api/admin/batch/${enable ? 'enable' : 'disable'}`, {
 | 
				
			||||||
@@ -266,9 +295,22 @@ function App() {
 | 
				
			|||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <div style={{ marginBottom: '20px' }}>
 | 
					              <div style={{ marginBottom: '20px' }}>
 | 
				
			||||||
                <button className="btn btn-success" onClick={triggerBatch}>
 | 
					                <button 
 | 
				
			||||||
                  バッチ処理を手動実行
 | 
					                  className="btn btn-success" 
 | 
				
			||||||
 | 
					                  onClick={triggerBatch}
 | 
				
			||||||
 | 
					                  disabled={stats?.batchScheduler?.isRunning}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  {stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'}
 | 
				
			||||||
                </button>
 | 
					                </button>
 | 
				
			||||||
 | 
					                {stats?.batchScheduler?.canForceStop && (
 | 
				
			||||||
 | 
					                  <button 
 | 
				
			||||||
 | 
					                    className="btn btn-danger" 
 | 
				
			||||||
 | 
					                    onClick={forceStopBatch}
 | 
				
			||||||
 | 
					                    style={{ marginLeft: '8px' }}
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                    強制停止
 | 
				
			||||||
 | 
					                  </button>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
                <button className="btn btn-primary" onClick={loadData} style={{ marginLeft: '8px' }}>
 | 
					                <button className="btn btn-primary" onClick={loadData} style={{ marginLeft: '8px' }}>
 | 
				
			||||||
                  データを再読み込み
 | 
					                  データを再読み込み
 | 
				
			||||||
                </button>
 | 
					                </button>
 | 
				
			||||||
@@ -404,15 +446,25 @@ function App() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
              <div style={{ marginBottom: '24px' }}>
 | 
					              <div style={{ marginBottom: '24px' }}>
 | 
				
			||||||
                <h4>手動実行</h4>
 | 
					                <h4>手動実行</h4>
 | 
				
			||||||
                <button 
 | 
					                <div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}>
 | 
				
			||||||
                  className="btn btn-primary"
 | 
					                  <button 
 | 
				
			||||||
                  onClick={triggerBatch}
 | 
					                    className="btn btn-primary"
 | 
				
			||||||
                  disabled={stats?.batchScheduler?.isRunning}
 | 
					                    onClick={triggerBatch}
 | 
				
			||||||
                >
 | 
					                    disabled={stats?.batchScheduler?.isRunning}
 | 
				
			||||||
                  {stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'}
 | 
					                  >
 | 
				
			||||||
                </button>
 | 
					                    {stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'}
 | 
				
			||||||
 | 
					                  </button>
 | 
				
			||||||
 | 
					                  {stats?.batchScheduler?.canForceStop && (
 | 
				
			||||||
 | 
					                    <button 
 | 
				
			||||||
 | 
					                      className="btn btn-danger"
 | 
				
			||||||
 | 
					                      onClick={forceStopBatch}
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
 | 
					                      強制停止
 | 
				
			||||||
 | 
					                    </button>
 | 
				
			||||||
 | 
					                  )}
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
                <p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
 | 
					                <p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
 | 
				
			||||||
                  スケジューラーの状態に関係なく、バッチ処理を手動で実行できます。
 | 
					                  スケジューラーの状態に関係なく、バッチ処理を手動で実行できます。実行中の場合は強制停止も可能です。
 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -423,6 +475,7 @@ function App() {
 | 
				
			|||||||
                  <li>新しいRSS記事の取得、要約生成、音声合成を行います</li>
 | 
					                  <li>新しいRSS記事の取得、要約生成、音声合成を行います</li>
 | 
				
			||||||
                  <li>スケジューラーが無効の場合、定期実行は停止しますが手動実行は可能です</li>
 | 
					                  <li>スケジューラーが無効の場合、定期実行は停止しますが手動実行は可能です</li>
 | 
				
			||||||
                  <li>バッチ処理の実行中は重複実行を防ぐため、新たな実行はスキップされます</li>
 | 
					                  <li>バッチ処理の実行中は重複実行を防ぐため、新たな実行はスキップされます</li>
 | 
				
			||||||
 | 
					                  <li><strong>強制停止:</strong> 実行中のバッチ処理を緊急停止できます(データ整合性に注意)</li>
 | 
				
			||||||
                </ul>
 | 
					                </ul>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </>
 | 
					            </>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -378,6 +378,31 @@ app.post("/api/admin/batch/trigger", async (c) => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.post("/api/admin/batch/force-stop", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    console.log("🛑 Force stop batch process requested via admin panel");
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const stopped = batchScheduler.forceStop();
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (stopped) {
 | 
				
			||||||
 | 
					      return c.json({ 
 | 
				
			||||||
 | 
					        result: "STOPPED",
 | 
				
			||||||
 | 
					        message: "Batch process force stop signal sent",
 | 
				
			||||||
 | 
					        timestamp: new Date().toISOString()
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      return c.json({ 
 | 
				
			||||||
 | 
					        result: "NO_PROCESS",
 | 
				
			||||||
 | 
					        message: "No batch process is currently running",
 | 
				
			||||||
 | 
					        timestamp: new Date().toISOString()
 | 
				
			||||||
 | 
					      }, 200);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error force stopping batch process:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to force stop batch process" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Static file handlers for admin panel UI
 | 
					// Static file handlers for admin panel UI
 | 
				
			||||||
app.get("/assets/*", async (c) => {
 | 
					app.get("/assets/*", async (c) => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -32,10 +32,15 @@ interface FeedItem {
 | 
				
			|||||||
 * Main batch processing function
 | 
					 * Main batch processing function
 | 
				
			||||||
 * Processes all feeds and generates podcasts for new articles
 | 
					 * Processes all feeds and generates podcasts for new articles
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export async function batchProcess(): Promise<void> {
 | 
					export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    console.log("🚀 Starting enhanced batch process...");
 | 
					    console.log("🚀 Starting enhanced batch process...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check for cancellation at start
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Batch process was cancelled before starting');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Load feed URLs from file
 | 
					    // Load feed URLs from file
 | 
				
			||||||
    const feedUrls = await loadFeedUrls();
 | 
					    const feedUrls = await loadFeedUrls();
 | 
				
			||||||
    if (feedUrls.length === 0) {
 | 
					    if (feedUrls.length === 0) {
 | 
				
			||||||
@@ -47,22 +52,42 @@ export async function batchProcess(): Promise<void> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // Process each feed URL
 | 
					    // Process each feed URL
 | 
				
			||||||
    for (const url of feedUrls) {
 | 
					    for (const url of feedUrls) {
 | 
				
			||||||
 | 
					      // Check for cancellation before processing each feed
 | 
				
			||||||
 | 
					      if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					        throw new Error('Batch process was cancelled during feed processing');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        await processFeedUrl(url);
 | 
					        await processFeedUrl(url, abortSignal);
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        // Re-throw cancellation errors
 | 
				
			||||||
 | 
					        if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
 | 
				
			||||||
 | 
					          throw error;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        console.error(`❌ Failed to process feed ${url}:`, error);
 | 
					        console.error(`❌ Failed to process feed ${url}:`, error);
 | 
				
			||||||
        // Continue with other feeds
 | 
					        // Continue with other feeds
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check for cancellation before processing articles
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Batch process was cancelled before article processing');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Process unprocessed articles and generate podcasts
 | 
					    // Process unprocessed articles and generate podcasts
 | 
				
			||||||
    await processUnprocessedArticles();
 | 
					    await processUnprocessedArticles(abortSignal);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    console.log(
 | 
					    console.log(
 | 
				
			||||||
      "✅ Enhanced batch process completed:",
 | 
					      "✅ Enhanced batch process completed:",
 | 
				
			||||||
      new Date().toISOString(),
 | 
					      new Date().toISOString(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
 | 
				
			||||||
 | 
					      console.log("🛑 Batch process was cancelled");
 | 
				
			||||||
 | 
					      const abortError = new Error('Batch process was cancelled');
 | 
				
			||||||
 | 
					      abortError.name = 'AbortError';
 | 
				
			||||||
 | 
					      throw abortError;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    console.error("💥 Batch process failed:", error);
 | 
					    console.error("💥 Batch process failed:", error);
 | 
				
			||||||
    throw error;
 | 
					    throw error;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -90,17 +115,27 @@ async function loadFeedUrls(): Promise<string[]> {
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * Process a single feed URL and discover new articles
 | 
					 * Process a single feed URL and discover new articles
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
async function processFeedUrl(url: string): Promise<void> {
 | 
					async function processFeedUrl(url: string, abortSignal?: AbortSignal): Promise<void> {
 | 
				
			||||||
  if (!url || !url.startsWith("http")) {
 | 
					  if (!url || !url.startsWith("http")) {
 | 
				
			||||||
    throw new Error(`Invalid feed URL: ${url}`);
 | 
					    throw new Error(`Invalid feed URL: ${url}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Check for cancellation
 | 
				
			||||||
 | 
					  if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					    throw new Error('Feed processing was cancelled');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  console.log(`🔍 Processing feed: ${url}`);
 | 
					  console.log(`🔍 Processing feed: ${url}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    // Parse RSS feed
 | 
					    // Parse RSS feed
 | 
				
			||||||
    const parser = new Parser<FeedItem>();
 | 
					    const parser = new Parser<FeedItem>();
 | 
				
			||||||
    const feed = await parser.parseURL(url);
 | 
					    const feed = await parser.parseURL(url);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Check for cancellation after parsing
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Feed processing was cancelled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Get or create feed record
 | 
					    // Get or create feed record
 | 
				
			||||||
    let feedRecord = await getFeedByUrl(url);
 | 
					    let feedRecord = await getFeedByUrl(url);
 | 
				
			||||||
@@ -189,12 +224,22 @@ async function discoverNewArticles(
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * Process unprocessed articles and generate podcasts
 | 
					 * Process unprocessed articles and generate podcasts
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
async function processUnprocessedArticles(): Promise<void> {
 | 
					async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<void> {
 | 
				
			||||||
  console.log("🎧 Processing unprocessed articles...");
 | 
					  console.log("🎧 Processing unprocessed articles...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
 | 
					    // Check for cancellation
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Article processing was cancelled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Process retry queue first
 | 
					    // Process retry queue first
 | 
				
			||||||
    await processRetryQueue();
 | 
					    await processRetryQueue(abortSignal);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check for cancellation after retry queue
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Article processing was cancelled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Get unprocessed articles (limit to prevent overwhelming)
 | 
					    // Get unprocessed articles (limit to prevent overwhelming)
 | 
				
			||||||
    const unprocessedArticles = await getUnprocessedArticles(
 | 
					    const unprocessedArticles = await getUnprocessedArticles(
 | 
				
			||||||
@@ -212,8 +257,13 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
    let successfullyGeneratedCount = 0;
 | 
					    let successfullyGeneratedCount = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const article of unprocessedArticles) {
 | 
					    for (const article of unprocessedArticles) {
 | 
				
			||||||
 | 
					      // Check for cancellation before processing each article
 | 
				
			||||||
 | 
					      if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					        throw new Error('Article processing was cancelled');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        const episodeCreated = await generatePodcastForArticle(article);
 | 
					        const episodeCreated = await generatePodcastForArticle(article, abortSignal);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        // Only mark as processed and update RSS if episode was actually created
 | 
					        // Only mark as processed and update RSS if episode was actually created
 | 
				
			||||||
        if (episodeCreated) {
 | 
					        if (episodeCreated) {
 | 
				
			||||||
@@ -233,6 +283,10 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
          console.warn(`⚠️  Episode creation failed for: ${article.title} - not marking as processed`);
 | 
					          console.warn(`⚠️  Episode creation failed for: ${article.title} - not marking as processed`);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
 | 
				
			||||||
 | 
					          console.log(`🛑 Article processing cancelled, stopping batch`);
 | 
				
			||||||
 | 
					          throw error; // Re-throw to propagate cancellation
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        console.error(
 | 
					        console.error(
 | 
				
			||||||
          `❌ Failed to generate podcast for article: ${article.title}`,
 | 
					          `❌ Failed to generate podcast for article: ${article.title}`,
 | 
				
			||||||
          error,
 | 
					          error,
 | 
				
			||||||
@@ -254,7 +308,7 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * Process retry queue for failed TTS generation
 | 
					 * Process retry queue for failed TTS generation
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
async function processRetryQueue(): Promise<void> {
 | 
					async function processRetryQueue(abortSignal?: AbortSignal): 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 { Database } = await import("bun:sqlite");
 | 
				
			||||||
  const db = new Database(config.paths.dbPath);
 | 
					  const db = new Database(config.paths.dbPath);
 | 
				
			||||||
@@ -271,6 +325,11 @@ async function processRetryQueue(): Promise<void> {
 | 
				
			|||||||
    console.log(`📋 Found ${queueItems.length} items in retry queue`);
 | 
					    console.log(`📋 Found ${queueItems.length} items in retry queue`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const item of queueItems) {
 | 
					    for (const item of queueItems) {
 | 
				
			||||||
 | 
					      // Check for cancellation before processing each retry item
 | 
				
			||||||
 | 
					      if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					        throw new Error('Retry queue processing was cancelled');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`);
 | 
					        console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
@@ -294,6 +353,11 @@ async function processRetryQueue(): Promise<void> {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
 | 
				
			||||||
 | 
					          console.log(`🛑 TTS retry processing cancelled for: ${item.itemId}`);
 | 
				
			||||||
 | 
					          throw error; // Re-throw cancellation errors
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
 | 
					        console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
@@ -330,20 +394,35 @@ async function processRetryQueue(): Promise<void> {
 | 
				
			|||||||
 * Generate podcast for a single article
 | 
					 * Generate podcast for a single article
 | 
				
			||||||
 * Returns true if episode was successfully created, false otherwise
 | 
					 * Returns true if episode was successfully created, false otherwise
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
async function generatePodcastForArticle(article: any): Promise<boolean> {
 | 
					async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal): Promise<boolean> {
 | 
				
			||||||
  console.log(`🎤 Generating podcast for: ${article.title}`);
 | 
					  console.log(`🎤 Generating podcast for: ${article.title}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
 | 
					    // Check for cancellation
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Podcast generation was cancelled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Get feed information for context
 | 
					    // Get feed information for context
 | 
				
			||||||
    const feed = await getFeedById(article.feedId);
 | 
					    const feed = await getFeedById(article.feedId);
 | 
				
			||||||
    const feedTitle = feed?.title || "Unknown Feed";
 | 
					    const feedTitle = feed?.title || "Unknown Feed";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check for cancellation before classification
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Podcast generation was cancelled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Classify the article/feed
 | 
					    // Classify the article/feed
 | 
				
			||||||
    const category = await openAI_ClassifyFeed(
 | 
					    const category = await openAI_ClassifyFeed(
 | 
				
			||||||
      `${feedTitle}: ${article.title}`,
 | 
					      `${feedTitle}: ${article.title}`,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    console.log(`🏷️  Article classified as: ${category}`);
 | 
					    console.log(`🏷️  Article classified as: ${category}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check for cancellation before content generation
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Podcast generation was cancelled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Generate podcast content for this single article
 | 
					    // Generate podcast content for this single article
 | 
				
			||||||
    const podcastContent = await openAI_GeneratePodcastContent(article.title, [
 | 
					    const podcastContent = await openAI_GeneratePodcastContent(article.title, [
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
@@ -351,6 +430,11 @@ async function generatePodcastForArticle(article: any): Promise<boolean> {
 | 
				
			|||||||
        link: article.link,
 | 
					        link: article.link,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    ]);
 | 
					    ]);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Check for cancellation before TTS
 | 
				
			||||||
 | 
					    if (abortSignal?.aborted) {
 | 
				
			||||||
 | 
					      throw new Error('Podcast generation was cancelled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Generate unique ID for the episode
 | 
					    // Generate unique ID for the episode
 | 
				
			||||||
    const episodeId = crypto.randomUUID();
 | 
					    const episodeId = crypto.randomUUID();
 | 
				
			||||||
@@ -410,6 +494,10 @@ async function generatePodcastForArticle(article: any): Promise<boolean> {
 | 
				
			|||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
 | 
				
			||||||
 | 
					      console.log(`🛑 Podcast generation cancelled for: ${article.title}`);
 | 
				
			||||||
 | 
					      throw error; // Re-throw cancellation errors to stop the batch
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    console.error(
 | 
					    console.error(
 | 
				
			||||||
      `💥 Error generating podcast for article: ${article.title}`,
 | 
					      `💥 Error generating podcast for article: ${article.title}`,
 | 
				
			||||||
      error,
 | 
					      error,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,13 +6,17 @@ interface BatchSchedulerState {
 | 
				
			|||||||
  nextRun?: string;
 | 
					  nextRun?: string;
 | 
				
			||||||
  isRunning: boolean;
 | 
					  isRunning: boolean;
 | 
				
			||||||
  intervalId?: NodeJS.Timeout;
 | 
					  intervalId?: NodeJS.Timeout;
 | 
				
			||||||
 | 
					  canForceStop: boolean;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BatchScheduler {
 | 
					class BatchScheduler {
 | 
				
			||||||
  private state: BatchSchedulerState = {
 | 
					  private state: BatchSchedulerState = {
 | 
				
			||||||
    enabled: true,
 | 
					    enabled: true,
 | 
				
			||||||
    isRunning: false,
 | 
					    isRunning: false,
 | 
				
			||||||
 | 
					    canForceStop: false,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  private currentAbortController?: AbortController;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
 | 
					  private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -64,16 +68,26 @@ class BatchScheduler {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.state.isRunning = true;
 | 
					    this.state.isRunning = true;
 | 
				
			||||||
 | 
					    this.state.canForceStop = true;
 | 
				
			||||||
    this.state.lastRun = new Date().toISOString();
 | 
					    this.state.lastRun = new Date().toISOString();
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Create new AbortController for this batch run
 | 
				
			||||||
 | 
					    this.currentAbortController = new AbortController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      console.log("🔄 Running scheduled batch process...");
 | 
					      console.log("🔄 Running scheduled batch process...");
 | 
				
			||||||
      await batchProcess();
 | 
					      await batchProcess(this.currentAbortController.signal);
 | 
				
			||||||
      console.log("✅ Scheduled batch process completed");
 | 
					      console.log("✅ Scheduled batch process completed");
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error("❌ Error during scheduled batch process:", error);
 | 
					      if (error instanceof Error && error.name === 'AbortError') {
 | 
				
			||||||
 | 
					        console.log("🛑 Batch process was forcefully stopped");
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        console.error("❌ Error during scheduled batch process:", error);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      this.state.isRunning = false;
 | 
					      this.state.isRunning = false;
 | 
				
			||||||
 | 
					      this.state.canForceStop = false;
 | 
				
			||||||
 | 
					      this.currentAbortController = undefined;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -117,6 +131,17 @@ class BatchScheduler {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public forceStop(): boolean {
 | 
				
			||||||
 | 
					    if (!this.state.isRunning || !this.currentAbortController) {
 | 
				
			||||||
 | 
					      console.log("ℹ️  No batch process currently running to stop");
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log("🛑 Force stopping batch process...");
 | 
				
			||||||
 | 
					    this.currentAbortController.abort();
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public isEnabled(): boolean {
 | 
					  public isEnabled(): boolean {
 | 
				
			||||||
    return this.state.enabled;
 | 
					    return this.state.enabled;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user