diff --git a/frontend/index.html b/frontend/index.html index be2dbf6..fe69810 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,11 +3,10 @@ - ポッドキャスト管理画面 - + Voice RSS Summary
- + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38a9949..ef21092 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,133 +1,38 @@ -import { useState } from "react"; -import "./app/globals.css"; -import Dashboard from "./components/Dashboard"; -import EpisodePlayer from "./components/EpisodePlayer"; -import FeedManager from "./components/FeedManager"; -import React from "react"; +import { useState } from 'react' +import EpisodeList from './components/EpisodeList' +import FeedManager from './components/FeedManager' -type TabType = "dashboard" | "episodes" | "feeds"; - -interface Tab { - id: TabType; - label: string; - icon: string; - description: string; -} - -const tabs: Tab[] = [ - { - id: "dashboard", - label: "ダッシュボード", - icon: "📊", - description: "システム概要と最新情報", - }, - { - id: "episodes", - label: "エピソード", - icon: "🎧", - description: "ポッドキャスト再生と管理", - }, - { - id: "feeds", - label: "フィード管理", - icon: "📡", - description: "RSSフィードの設定", - }, -]; - -export default function App() { - const [activeTab, setActiveTab] = useState("dashboard"); - const [isMenuOpen, setIsMenuOpen] = useState(false); - - const activeTabInfo = tabs.find((tab) => tab.id === activeTab); +function App() { + const [activeTab, setActiveTab] = useState<'episodes' | 'feeds'>('episodes') return ( - - - - - Voice RSS Summary - AI音声ポッドキャスト生成システム - - - -
- {/* Header */} -
-
-
- {/* Status and Mobile Menu */} -
- {/* System Status */} -
-
- 稼働中 -
+
+
+
Voice RSS Summary
+
RSS フィードから自動生成された音声ポッドキャスト
+
- {/* Mobile menu button */} - -
-
-
-
+
+ + +
- {/* Navigation */} - - - {/* Main Content */} -
-
- {activeTab === "dashboard" && } - {activeTab === "episodes" && } - {activeTab === "feeds" && } -
-
- - {/* Footer */} -
-
-
-

- © 2025 Voice RSS Summary. All rights reserved. -

-
-
-
-
- - - ); +
+ {activeTab === 'episodes' && } + {activeTab === 'feeds' && } +
+ + ) } + +export default App \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css deleted file mode 100644 index 482dc93..0000000 --- a/frontend/src/app/globals.css +++ /dev/null @@ -1,125 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Minimal CSS Variables */ -:root { - --primary: #3b82f6; - --primary-hover: #2563eb; - --border: #e5e7eb; - --border-hover: #d1d5db; - --background: #ffffff; - --muted: #f9fafb; - --text: #111827; - --text-muted: #6b7280; -} - - -.line-clamp-1 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; -} - -.line-clamp-2 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.slider { - background: #e5e7eb; -} - -.slider::-webkit-slider-thumb { - appearance: none; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--primary); - cursor: pointer; - border: 2px solid white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.slider::-moz-range-thumb { - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--primary); - cursor: pointer; - border: 2px solid white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -button:focus, -input:focus, -select:focus, -textarea:focus { - outline: 2px solid var(--primary); - outline-offset: 2px; -} - - -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: #f1f5f9; -} - -::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} - -.btn-primary { - background: var(--primary); - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 0.375rem; - font-weight: 500; - transition: background-color 0.2s; -} - -.btn-primary:hover { - background: var(--primary-hover); -} - - -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - } -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - padding: 0 1rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f9fafb; - color: var(--text); - line-height: 1.5; -} - -* { - box-sizing: border-box; -} - -::selection { - background: #dbeafe; - color: #1e40af; -} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx deleted file mode 100644 index 16d196e..0000000 --- a/frontend/src/app/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import "./globals.css"; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - -
-
-

ポッドキャスト管理画面

-
-
{children}
-
-

© 2025 Podcast Generator

-
-
- - - ); -} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx deleted file mode 100644 index d5c3816..0000000 --- a/frontend/src/app/page.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useState } from "react"; -import FeedManager from "../components/FeedManager"; -import EpisodePlayer from "../components/EpisodePlayer"; -import Dashboard from "../components/Dashboard"; -import React from "react"; - -export const metadata = { - title: "Voice RSS Summary", - description: - "RSSフィードから自動生成された音声ポッドキャストをご紹介します。", -}; - -export default function Home() { - const [activeTab, setActiveTab] = useState< - "dashboard" | "episodes" | "feeds" - >("dashboard"); - - return ( -
- {/* Header */} -
-
-
-
-
-

- Voice RSS Summary -

-

- AI音声ポッドキャスト自動生成システム -

-
-
-
-
-
- - システム稼働中 - -
-
-
-
-
- - {/* Navigation */} - - - {/* Main Content */} -
-
- {activeTab === "dashboard" && ( -
- -
- )} - {activeTab === "episodes" && ( -
-
-
- -
-
-

- エピソード管理 -

-

- ポッドキャストエピソードの再生と管理 -

-
-
- -
- )} - {activeTab === "feeds" && ( -
-
-
- -
-
-

- フィード管理 -

-

- RSSフィードの追加と管理 -

-
-
- -
- )} -
-
- - {/* Footer */} -
-
-
-
-
-
- - Voice RSS Summary - -
-

- AI技術により最新のニュースを音声でお届けします。 -
- 自動化されたポッドキャスト生成で、いつでもどこでも情報をキャッチアップ。 -

-
-

- © 2025 Voice RSS Summary. All rights reserved. -

-
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx deleted file mode 100644 index 500845c..0000000 --- a/frontend/src/components/Dashboard.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { useEffect, useState } from "react"; -import React from "react"; - -interface Stats { - totalFeeds: number; - activeFeeds: number; - totalEpisodes: number; - lastUpdated: string; -} - -interface RecentEpisode { - id: string; - title: string; - createdAt: string; - article: { - title: string; - link: string; - }; - feed: { - title: string; - }; -} - -export default function Dashboard() { - const [stats, setStats] = useState(null); - const [recentEpisodes, setRecentEpisodes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - fetchDashboardData(); - }, []); - - const fetchDashboardData = async () => { - try { - // Fetch stats and recent episodes in parallel - const [statsResponse, episodesResponse] = await Promise.all([ - fetch("/api/stats"), - fetch("/api/episodes"), - ]); - - if (!statsResponse.ok || !episodesResponse.ok) { - throw new Error("Failed to fetch dashboard data"); - } - - const statsData = await statsResponse.json(); - const episodesData = await episodesResponse.json(); - - setStats(statsData); - setRecentEpisodes(episodesData.slice(0, 5)); // Show latest 5 episodes - setLoading(false); - } catch (err) { - setError(err instanceof Error ? err.message : "エラーが発生しました"); - setLoading(false); - } - }; - - const triggerBatchProcess = async () => { - try { - const response = await fetch("/api/batch/trigger", { method: "POST" }); - if (response.ok) { - alert( - "バッチ処理を開始しました。新しいエピソードの生成には時間がかかる場合があります。", - ); - } else { - alert("バッチ処理の開始に失敗しました。"); - } - } catch (error) { - alert("エラーが発生しました。"); - console.error("Batch process trigger error:", error); - } - }; - - if (loading) { - return ( -
-
-
- 読み込み中... -
-
- ); - } - - if (error) { - return ( -
-
-
-

エラー

-

{error}

-
-
-
- ); - } - - return ( -
- {/* Stats Cards */} -
-
-
-
-

総フィード数

-

- {stats?.totalFeeds || 0} -

-
-
- 📡 -
-
-
- -
-
-
-

- アクティブフィード -

-

- {stats?.activeFeeds || 0} -

-
-
- -
-
-
- -
-
-
-

- 総エピソード数 -

-

- {stats?.totalEpisodes || 0} -

-
-
- 🎧 -
-
-
- -
-
-
-

最終更新

-

- {stats?.lastUpdated - ? new Date(stats.lastUpdated).toLocaleDateString("ja-JP") - : "未取得"} -

-
-
- 🕒 -
-
-
-
- - {/* Action Cards */} -
- {/* Manual Batch Execution */} -
-

手動バッチ実行

-

- 新しい記事をすぐにチェックしてポッドキャストを生成します。 -

- -
- - {/* System Status */} -
-

- システム状態 -

- -
-
-
-
- - 自動バッチ処理 - -

6時間間隔で実行中

-
-
- -
-
-
- - AI音声生成 - -

VOICEVOX連携済み

-
-
-
-
-
- - {/* Recent Episodes */} -
-
-
-

- 最新エピソード -

- - {recentEpisodes.length} エピソード - -
-
- -
- {recentEpisodes.length === 0 ? ( -
-
🎧
-

- まだエピソードがありません -

-

- フィードを追加してバッチ処理を実行してください。 -

-
- ) : ( -
- {recentEpisodes.map((episode) => ( -
-
- 🎵 -
- -
-

- {episode.title} -

- -
- {episode.feed?.title} - - - {new Date(episode.createdAt).toLocaleDateString( - "ja-JP", - )} - -
- - {episode.article && ( - - 元記事を読む - - )} -
- - - 生成済み - -
- ))} -
- )} -
-
-
- ); -} diff --git a/frontend/src/components/EpisodeList.tsx b/frontend/src/components/EpisodeList.tsx new file mode 100644 index 0000000..c07015d --- /dev/null +++ b/frontend/src/components/EpisodeList.tsx @@ -0,0 +1,138 @@ +import { useState, useEffect } from 'react' + +interface Episode { + id: string + title: string + audioPath: string + createdAt: string + article?: { + link: string + } + feed?: { + title: string + } +} + +function EpisodeList() { + const [episodes, setEpisodes] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [currentAudio, setCurrentAudio] = useState(null) + + useEffect(() => { + fetchEpisodes() + }, []) + + const fetchEpisodes = async () => { + try { + setLoading(true) + const response = await fetch('/api/episodes') + if (!response.ok) throw new Error('エピソードの取得に失敗しました') + const data = await response.json() + setEpisodes(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'エラーが発生しました') + } finally { + setLoading(false) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString('ja-JP') + } + + const playAudio = (audioPath: string) => { + if (currentAudio) { + const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement + if (currentPlayer) { + currentPlayer.pause() + currentPlayer.currentTime = 0 + } + } + setCurrentAudio(audioPath) + } + + if (loading) { + return
読み込み中...
+ } + + if (error) { + return
{error}
+ } + + if (episodes.length === 0) { + return ( +
+

エピソードがありません

+

フィード管理でRSSフィードを追加してください

+
+ ) + } + + return ( +
+
+

エピソード一覧 ({episodes.length}件)

+ +
+ + + + + + + + + + + + {episodes.map((episode) => ( + + + + + + + ))} + +
タイトルフィード作成日時操作
+
+ {episode.title} +
+ {episode.article?.link && ( + + 元記事を見る + + )} +
{episode.feed?.title || '不明'}{formatDate(episode.createdAt)} + + {currentAudio === episode.audioPath && ( +
+
+ )} +
+
+ ) +} + +export default EpisodeList \ No newline at end of file diff --git a/frontend/src/components/EpisodePlayer.tsx b/frontend/src/components/EpisodePlayer.tsx deleted file mode 100644 index 163d4aa..0000000 --- a/frontend/src/components/EpisodePlayer.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import { useEffect, useState, useRef } from "react"; -import React from "react"; - -interface Episode { - id: string; - title: string; - description?: string; - audioPath: string; - duration?: number; - fileSize?: number; - createdAt: string; - article: { - id: string; - title: string; - link: string; - description?: string; - pubDate: string; - }; - feed: { - id: string; - title?: string; - url: string; - }; -} - -export default function EpisodePlayer() { - const [episodes, setEpisodes] = useState([]); - const [selectedEpisode, setSelectedEpisode] = useState(null); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - - const audioRef = useRef(null); - - useEffect(() => { - fetchEpisodes(); - }, []); - - useEffect(() => { - const audio = audioRef.current; - if (!audio) return; - - const updateTime = () => setCurrentTime(audio.currentTime); - const updateDuration = () => setDuration(audio.duration); - const handleEnded = () => setIsPlaying(false); - - audio.addEventListener("timeupdate", updateTime); - audio.addEventListener("loadedmetadata", updateDuration); - audio.addEventListener("ended", handleEnded); - - return () => { - audio.removeEventListener("timeupdate", updateTime); - audio.removeEventListener("loadedmetadata", updateDuration); - audio.removeEventListener("ended", handleEnded); - }; - }, [selectedEpisode, isPlaying, audioRef, currentTime, duration]); - - const fetchEpisodes = async () => { - try { - const response = await fetch("/api/episodes"); - if (!response.ok) { - throw new Error("エピソードの取得に失敗しました"); - } - const data = await response.json(); - setEpisodes(data); - setLoading(false); - } catch (err) { - setError(err instanceof Error ? err.message : "エラーが発生しました"); - setLoading(false); - } - }; - - const handlePlay = (episode: Episode) => { - if (selectedEpisode?.id === episode.id) { - // Toggle play/pause for the same episode - if (isPlaying) { - audioRef.current?.pause(); - setIsPlaying(false); - } else { - audioRef.current?.play(); - setIsPlaying(true); - } - } else { - // Play new episode - setSelectedEpisode(episode); - setIsPlaying(true); - setCurrentTime(0); - } - }; - - const handleSeek = (e: React.ChangeEvent) => { - const audio = audioRef.current; - if (audio) { - const newTime = parseFloat(e.target.value); - audio.currentTime = newTime; - setCurrentTime(newTime); - } - }; - - const formatTime = (time: number) => { - if (isNaN(time)) return "0:00"; - const minutes = Math.floor(time / 60); - const seconds = Math.floor(time % 60); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - - const formatFileSize = (bytes?: number) => { - if (!bytes) return "不明"; - const mb = bytes / (1024 * 1024); - return `${mb.toFixed(1)} MB`; - }; - - const filteredEpisodes = episodes.filter( - (episode) => - episode.title.toLowerCase().includes(searchTerm.toLowerCase()) || - episode.article.title.toLowerCase().includes(searchTerm.toLowerCase()) || - episode.feed.title?.toLowerCase().includes(searchTerm.toLowerCase()), - ); - - if (loading) { - return ( -
-
-
- 読み込み中... -
-
- ); - } - - if (error) { - return ( -
-
-
-

エラー

-

{error}

-
-
-
- ); - } - - return ( -
- {/* Search */} -
- setSearchTerm(e.target.value)} - className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" - /> -
- - {/* Audio Player */} - {selectedEpisode && ( -
-
-
- -
-
-

- {selectedEpisode.title} -

-

- {selectedEpisode.feed.title} -

-
- -
- - {/* Progress Bar */} -
- -
- {formatTime(currentTime)} - {formatTime(duration)} -
-
- - {/* Episode Info */} -
- {selectedEpisode.description && ( -

{selectedEpisode.description}

- )} -
- - 🗓️{" "} - {new Date(selectedEpisode.createdAt).toLocaleDateString( - "ja-JP", - )} - - 💾 {formatFileSize(selectedEpisode.fileSize)} - {selectedEpisode.article.link && ( - - 📄 元記事 - - )} -
-
- -
- )} - - {/* Episodes List */} -
-
-

- エピソード一覧 -

- - {filteredEpisodes.length} エピソード - -
- - {filteredEpisodes.length === 0 ? ( -
-
- -
-

- {searchTerm ? "検索結果がありません" : "エピソードがありません"} -

-

- {searchTerm - ? "別のキーワードで検索してみてください" - : "フィードを追加してバッチ処理を実行してください"} -

-
- ) : ( -
- {filteredEpisodes.map((episode) => ( -
handlePlay(episode)} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handlePlay(episode); - } - }} - aria-label={`エピソード: ${episode.title}`} - > -
-
-

- {episode.title} -

-

- {episode.feed.title} -

- {episode.description && ( -

- {episode.description} -

- )} -
- - 📅{" "} - {new Date(episode.createdAt).toLocaleDateString( - "ja-JP", - )} - - 💾 {formatFileSize(episode.fileSize)} - {episode.article.link && ( - e.stopPropagation()} - > - 📄 元記事 - - )} -
-
-
-
- ))} -
- )} -
-
- ); -} diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx deleted file mode 100644 index e7b8804..0000000 --- a/frontend/src/components/FeedList.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { useEffect, useState } from "react"; -import React from "react"; - -interface FeedItem { - id: string; - title: string; - link: string; - pubDate: string; - contentSnippet?: string; - source?: { - title?: string; - url?: string; - }; - category?: string; -} - -interface FeedListProps { - searchTerm?: string; - categoryFilter?: string; -} - -export default function FeedList({ - searchTerm = "", - categoryFilter = "", -}: FeedListProps = {}) { - const [feeds, setFeeds] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [sortBy, setSortBy] = useState<"date" | "title">("date"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - - useEffect(() => { - fetchFeeds(); - }, []); - - const fetchFeeds = async () => { - try { - setLoading(true); - const response = await fetch("/api/feeds"); - if (!response.ok) { - throw new Error("フィードの取得に失敗しました"); - } - const data = await response.json(); - setFeeds(data); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : "エラーが発生しました"); - } finally { - setLoading(false); - } - }; - - const filteredAndSortedFeeds = feeds - .filter((feed) => { - const matchesSearch = - !searchTerm || - feed.title.toLowerCase().includes(searchTerm.toLowerCase()) || - feed.contentSnippet?.toLowerCase().includes(searchTerm.toLowerCase()) || - feed.source?.title?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesCategory = - !categoryFilter || feed.category === categoryFilter; - - return matchesSearch && matchesCategory; - }) - .sort((a, b) => { - const multiplier = sortOrder === "asc" ? 1 : -1; - - if (sortBy === "date") { - return ( - (new Date(a.pubDate).getTime() - new Date(b.pubDate).getTime()) * - multiplier - ); - } else { - return a.title.localeCompare(b.title) * multiplier; - } - }); - - const handleSort = (field: "date" | "title") => { - if (sortBy === field) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(field); - setSortOrder("desc"); - } - }; - - const formatDate = (dateString: string) => { - try { - return new Date(dateString).toLocaleDateString("ja-JP", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - } catch { - return dateString; - } - }; - - if (loading) { - return ( -
- {[...Array(5)].map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
- ); - } - - if (error) { - return ( -
-
-
- ⚠️ -
-
-

- エラーが発生しました -

-

{error}

- -
-
-
- ); - } - - return ( -
- {/* Sort Controls */} -
-
- - 並び替え: - -
- - -
-
- {filteredAndSortedFeeds.length} / {feeds.length} 件表示中 -
-
-
- - {/* Feed Cards */} - {filteredAndSortedFeeds.length === 0 ? ( -
-
- -
-

- {searchTerm || categoryFilter - ? "検索結果がありません" - : "フィードがありません"} -

-

- {searchTerm || categoryFilter - ? "別のキーワードやカテゴリで検索してみてください" - : "RSSフィードを追加してバッチ処理を実行してください"} -

-
- ) : ( -
- {filteredAndSortedFeeds.map((feed, index) => ( -
-
-
- {/* Article Icon */} -
-
- -
-
- - {/* Article Content */} -
- {/* Header */} -
-
-

- {feed.title} -

- - {/* Meta Info */} -
- {feed.source?.title && ( - - {feed.source.title} - - )} - - {formatDate(feed.pubDate)} -
-
- - {/* Category Badge */} - {feed.category && ( - - {feed.category} - - )} -
- - {/* Content Snippet */} - {feed.contentSnippet && ( -

- {feed.contentSnippet} -

- )} - - {/* Actions */} -
- - 元記事を読む → - - -
#{index + 1}
-
-
-
-
-
- ))} -
- )} -
- ); -} diff --git a/frontend/src/components/FeedManager.tsx b/frontend/src/components/FeedManager.tsx index aa9c128..3c14959 100644 --- a/frontend/src/components/FeedManager.tsx +++ b/frontend/src/components/FeedManager.tsx @@ -1,263 +1,213 @@ -import { useEffect, useState } from "react"; -import React from "react"; +import { useState, useEffect } from 'react' interface Feed { - id: string; - url: string; - title?: string; - description?: string; - lastUpdated?: string; - createdAt: string; - active: boolean; + id: string + url: string + title?: string + description?: string + active: boolean + lastUpdated?: string } -export default function FeedManager() { - const [feeds, setFeeds] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [newFeedUrl, setNewFeedUrl] = useState(""); - const [addingFeed, setAddingFeed] = useState(false); +function FeedManager() { + const [feeds, setFeeds] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [newFeedUrl, setNewFeedUrl] = useState('') + const [adding, setAdding] = useState(false) useEffect(() => { - fetchFeeds(); - }, []); + fetchFeeds() + }, []) const fetchFeeds = async () => { try { - setLoading(true); - const response = await fetch("/api/feeds"); - if (!response.ok) { - throw new Error("フィードの取得に失敗しました"); - } - const data = await response.json(); - setFeeds(data); - setError(null); + setLoading(true) + const response = await fetch('/api/feeds') + if (!response.ok) throw new Error('フィードの取得に失敗しました') + const data = await response.json() + setFeeds(data) } catch (err) { - setError(err instanceof Error ? err.message : "エラーが発生しました"); + setError(err instanceof Error ? err.message : 'エラーが発生しました') } finally { - setLoading(false); + setLoading(false) } - }; + } const addFeed = async (e: React.FormEvent) => { - e.preventDefault(); + e.preventDefault() + if (!newFeedUrl.trim()) return - if (!newFeedUrl.trim()) { - alert("フィードURLを入力してください"); - return; + try { + setAdding(true) + setError(null) + setSuccess(null) + + const response = await fetch('/api/feeds', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url: newFeedUrl.trim() }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'フィードの追加に失敗しました') + } + + setSuccess('フィードを追加しました') + setNewFeedUrl('') + await fetchFeeds() + } catch (err) { + setError(err instanceof Error ? err.message : 'エラーが発生しました') + } finally { + setAdding(false) } + } - if (!newFeedUrl.startsWith("http")) { - alert("有効なURLを入力してください"); - return; + const deleteFeed = async (feedId: string) => { + if (!confirm('このフィードを削除しますか?関連するエピソードも削除されます。')) { + return } try { - setAddingFeed(true); - const response = await fetch("/api/feeds", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ feedUrl: newFeedUrl }), - }); + setError(null) + setSuccess(null) - const result = await response.json(); + const response = await fetch(`/api/feeds/${feedId}`, { + method: 'DELETE', + }) - if (response.ok) { - if (result.result === "EXISTS") { - alert("このフィードは既に登録されています"); - } else { - alert("フィードが正常に追加されました"); - setNewFeedUrl(""); - fetchFeeds(); // Refresh the list - } - } else { - alert(result.error || "フィードの追加に失敗しました"); + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'フィードの削除に失敗しました') } + + setSuccess('フィードを削除しました') + await fetchFeeds() } catch (err) { - alert("エラーが発生しました"); - console.error("Feed addition error:", err); - } finally { - setAddingFeed(false); + setError(err instanceof Error ? err.message : 'エラーが発生しました') } - }; + } + + const toggleFeed = async (feedId: string, active: boolean) => { + try { + setError(null) + setSuccess(null) + + const response = await fetch(`/api/feeds/${feedId}/toggle`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ active }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'フィードの状態変更に失敗しました') + } + + setSuccess(`フィードを${active ? '有効' : '無効'}にしました`) + await fetchFeeds() + } catch (err) { + setError(err instanceof Error ? err.message : 'エラーが発生しました') + } + } + + const formatDate = (dateString?: string) => { + if (!dateString) return '未更新' + return new Date(dateString).toLocaleString('ja-JP') + } if (loading) { - return ( -
-
-
- 読み込み中... -
-
- ); + return
読み込み中...
} return ( -
- {/* Add New Feed Form */} -
-

- 新しいフィードを追加 -

-
-
- -
- setNewFeedUrl(e.target.value)} - placeholder="https://example.com/feed.xml" - className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" - disabled={addingFeed} - aria-describedby="feedUrl-help" - /> - -
-

- RSS または Atom フィードの URL を入力してください -

+
+ {error &&
{error}
} + {success &&
{success}
} + +
+

新しいフィードを追加

+ +
+ + setNewFeedUrl(e.target.value)} + placeholder="https://example.com/feed.xml" + required + />
+
- {/* Error Display */} - {error && ( -
-
-
-

エラー

-

{error}

-
-
-
- )} - - {/* Feeds List */}
-
-

- 登録済みフィード -

- {feeds.length} フィード -
- +

登録済みフィード ({feeds.length}件)

+ {feeds.length === 0 ? ( -
-
- -
-

- フィードがありません -

-

- 上のフォームから RSS フィードを追加してください -

+
+

登録されているフィードがありません

) : ( -
+
{feeds.map((feed) => ( -
-
-
-
-
-

- {feed.title || "タイトル未取得"} -

-
- - {feed.description && ( -

- {feed.description} -

+
+
+
+ {feed.title || 'タイトル不明'} + {!feed.active && ( + + 無効 + )} - -
-
- - URL: - - - {feed.url} - -
- -
- - 追加日:{" "} - {new Date(feed.createdAt).toLocaleDateString("ja-JP")} - - {feed.lastUpdated && ( - - 最終更新:{" "} - {new Date(feed.lastUpdated).toLocaleDateString( - "ja-JP", - )} - - )} -
-
- -
- - {feed.active ? "アクティブ" : "無効"} - - - {/* Future: Add edit/delete buttons here */} - +
{feed.url}
+
+ 最終更新: {formatDate(feed.lastUpdated)}
+
+ + +
))}
)}
- ); + ) } + +export default FeedManager \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 722f6c5..f615309 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; -import React from "react"; +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './styles.css' -createRoot(document.getElementById("root")!).render( +createRoot(document.getElementById('root')!).render( , -); +) \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..d726c5a --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,225 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.header { + background-color: #fff; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.title { + font-size: 24px; + font-weight: bold; + margin-bottom: 8px; +} + +.subtitle { + color: #666; + font-size: 14px; +} + +.tabs { + display: flex; + background-color: #fff; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.tab { + flex: 1; + padding: 15px 20px; + border: none; + background: transparent; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s; +} + +.tab:first-child { + border-radius: 8px 0 0 8px; +} + +.tab:last-child { + border-radius: 0 8px 8px 0; +} + +.tab.active { + background-color: #007bff; + color: white; +} + +.tab:hover:not(.active) { + background-color: #f8f9fa; +} + +.content { + background-color: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} + +.table th, +.table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #e9ecef; +} + +.table th { + background-color: #f8f9fa; + font-weight: 600; +} + +.table tr:hover { + background-color: #f8f9fa; +} + +.btn { + display: inline-block; + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + text-decoration: none; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #007bff; + color: white; +} + +.btn-primary:hover { + background-color: #0056b3; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #545b62; +} + +.form-group { + margin-bottom: 15px; +} + +.form-label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +.form-input { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0,123,255,0.25); +} + +.audio-player { + width: 100%; + margin: 10px 0; +} + +.loading { + text-align: center; + padding: 40px; + color: #666; +} + +.error { + background-color: #f8d7da; + color: #721c24; + padding: 10px; + border-radius: 4px; + margin-bottom: 20px; +} + +.success { + background-color: #d4edda; + color: #155724; + padding: 10px; + border-radius: 4px; + margin-bottom: 20px; +} + +.empty-state { + text-align: center; + padding: 40px; + color: #666; +} + +.feed-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + border: 1px solid #e9ecef; + border-radius: 4px; + margin-bottom: 10px; +} + +.feed-info { + flex: 1; +} + +.feed-title { + font-weight: 500; + margin-bottom: 4px; +} + +.feed-url { + color: #666; + font-size: 14px; +} + +.feed-actions { + display: flex; + gap: 10px; +} \ No newline at end of file diff --git a/schema.sql b/schema.sql index d8ccebd..f895fee 100644 --- a/schema.sql +++ b/schema.sql @@ -42,9 +42,22 @@ CREATE TABLE IF NOT EXISTS processed_feed_items ( PRIMARY KEY(feed_url, item_id) ); +-- TTS generation retry queue +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 indexes for better performance 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_tts_queue_status ON tts_queue(status); +CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at); diff --git a/scripts/fetch_and_generate.ts b/scripts/fetch_and_generate.ts index 5c1a516..7471058 100644 --- a/scripts/fetch_and_generate.ts +++ b/scripts/fetch_and_generate.ts @@ -192,6 +192,9 @@ async function processUnprocessedArticles(): Promise { console.log("🎧 Processing unprocessed articles..."); try { + // Process retry queue first + await processRetryQueue(); + // Get unprocessed articles (limit to prevent overwhelming) const unprocessedArticles = await getUnprocessedArticles( Number.parseInt(import.meta.env["LIMIT_UNPROCESSED_ARTICLES"] || "10"), @@ -204,12 +207,15 @@ async function processUnprocessedArticles(): Promise { console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`); + // Track articles that successfully generated audio + const successfullyGeneratedArticles: string[] = []; + for (const article of unprocessedArticles) { try { await generatePodcastForArticle(article); await markArticleAsProcessed(article.id); console.log(`✅ Podcast generated for: ${article.title}`); - await updatePodcastRSS(); // Update RSS after each article + successfullyGeneratedArticles.push(article.id); } catch (error) { console.error( `❌ Failed to generate podcast for article: ${article.title}`, @@ -218,12 +224,68 @@ async function processUnprocessedArticles(): Promise { // Don't mark as processed if generation failed } } + + // Only update RSS if at least one article was successfully processed + if (successfullyGeneratedArticles.length > 0) { + console.log(`📻 Updating podcast RSS for ${successfullyGeneratedArticles.length} new episodes...`); + await updatePodcastRSS(); + } } catch (error) { console.error("💥 Error processing unprocessed articles:", error); throw error; } } +/** + * Process retry queue for failed TTS generation + */ +async function processRetryQueue(): Promise { + const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js"); + + console.log("🔄 Processing TTS retry queue..."); + + try { + const queueItems = await getQueueItems(5); // Process 5 items at a time + + if (queueItems.length === 0) { + return; + } + + console.log(`📋 Found ${queueItems.length} items in retry queue`); + + for (const item of queueItems) { + try { + console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1})`); + + // Mark as processing + await updateQueueItemStatus(item.id, 'processing'); + + // Attempt TTS generation + await generateTTS(item.itemId, item.scriptText, item.retryCount); + + // Success - remove from queue + await removeFromQueue(item.id); + console.log(`✅ TTS retry successful for: ${item.itemId}`); + + } catch (error) { + console.error(`❌ TTS retry failed for: ${item.itemId}`, error); + + if (item.retryCount >= 2) { + // Max retries reached, mark as failed + await updateQueueItemStatus(item.id, 'failed'); + console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`); + } else { + // Reset to pending for next retry + await updateQueueItemStatus(item.id, 'pending'); + } + } + } + } catch (error) { + console.error("💥 Error processing retry queue:", error); + throw error; + } +} + /** * Generate podcast for a single article */ diff --git a/server.ts b/server.ts index 6ca135e..d6659c6 100644 --- a/server.ts +++ b/server.ts @@ -107,6 +107,88 @@ app.get("/", serveIndex); app.get("/index.html", serveIndex); +// API endpoints for frontend +app.get("/api/episodes", async (c) => { + try { + const { fetchEpisodesWithArticles } = await import("./services/database.js"); + const episodes = await fetchEpisodesWithArticles(); + return c.json(episodes); + } catch (error) { + console.error("Error fetching episodes:", error); + return c.json({ error: "Failed to fetch episodes" }, 500); + } +}); + +app.get("/api/feeds", async (c) => { + try { + const { getAllFeedsIncludingInactive } = await import("./services/database.js"); + const feeds = await getAllFeedsIncludingInactive(); + return c.json(feeds); + } catch (error) { + console.error("Error fetching feeds:", error); + return c.json({ error: "Failed to fetch feeds" }, 500); + } +}); + +app.post("/api/feeds", async (c) => { + try { + const body = await c.req.json(); + const { url } = body; + + if (!url || typeof url !== 'string') { + return c.json({ error: "URL is required" }, 400); + } + + const { addNewFeedUrl } = await import("./scripts/fetch_and_generate.js"); + await addNewFeedUrl(url); + return c.json({ success: true, message: "Feed added successfully" }); + } catch (error) { + console.error("Error adding feed:", error); + return c.json({ error: "Failed to add feed" }, 500); + } +}); + +app.delete("/api/feeds/:id", async (c) => { + try { + const feedId = c.req.param("id"); + const { deleteFeed } = await import("./services/database.js"); + const success = await deleteFeed(feedId); + + if (!success) { + return c.json({ error: "Feed not found" }, 404); + } + + return c.json({ success: true, message: "Feed deleted successfully" }); + } catch (error) { + console.error("Error deleting feed:", error); + return c.json({ error: "Failed to delete feed" }, 500); + } +}); + +app.patch("/api/feeds/:id/toggle", async (c) => { + try { + const feedId = c.req.param("id"); + const body = await c.req.json(); + const { active } = body; + + if (typeof active !== 'boolean') { + return c.json({ error: "Active status must be boolean" }, 400); + } + + const { toggleFeedActive } = await import("./services/database.js"); + const success = await toggleFeedActive(feedId, active); + + if (!success) { + return c.json({ error: "Feed not found" }, 404); + } + + return c.json({ success: true, message: "Feed status updated successfully" }); + } catch (error) { + console.error("Error toggling feed:", error); + return c.json({ error: "Failed to update feed status" }, 500); + } +}); + // Catch-all for SPA routing app.get("*", serveIndex); diff --git a/services/database.ts b/services/database.ts index e5cc400..d427d2d 100644 --- a/services/database.ts +++ b/services/database.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); } diff --git a/services/podcast.ts b/services/podcast.ts index 8759141..627bb31 100644 --- a/services/podcast.ts +++ b/services/podcast.ts @@ -45,9 +45,22 @@ function createItemXml(episode: Episode): string { export async function updatePodcastRSS(): Promise { 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 { 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; diff --git a/services/tts.ts b/services/tts.ts index bb145b7..74b4f8d 100644 --- a/services/tts.ts +++ b/services/tts.ts @@ -15,6 +15,7 @@ const defaultVoiceStyle: VoiceStyle = { export async function generateTTS( itemId: string, scriptText: string, + retryCount: number = 0, ): Promise { 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); }