Update
This commit is contained in:
@ -3,11 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ポッドキャスト管理画面</title>
|
<title>Voice RSS Summary</title>
|
||||||
<meta name="description" content="RSSフィードから自動生成された音声ポッドキャストを再生・管理できます。" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./src/main.tsx" defer></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,133 +1,38 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react'
|
||||||
import "./app/globals.css";
|
import EpisodeList from './components/EpisodeList'
|
||||||
import Dashboard from "./components/Dashboard";
|
import FeedManager from './components/FeedManager'
|
||||||
import EpisodePlayer from "./components/EpisodePlayer";
|
|
||||||
import FeedManager from "./components/FeedManager";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type TabType = "dashboard" | "episodes" | "feeds";
|
function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'episodes' | 'feeds'>('episodes')
|
||||||
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<TabType>("dashboard");
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const activeTabInfo = tabs.find((tab) => tab.id === activeTab);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="ja">
|
<div className="container">
|
||||||
<head>
|
<div className="header">
|
||||||
<meta charSet="UTF-8" />
|
<div className="title">Voice RSS Summary</div>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<div className="subtitle">RSS フィードから自動生成された音声ポッドキャスト</div>
|
||||||
<title>Voice RSS Summary - AI音声ポッドキャスト生成システム</title>
|
</div>
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="RSSフィードから自動生成された音声ポッドキャストをご紹介します。"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-screen bg-gray-50">
|
|
||||||
<div className="min-h-screen flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between items-center py-4">
|
|
||||||
{/* Status and Mobile Menu */}
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{/* System Status */}
|
|
||||||
<div className="hidden md:flex items-center space-x-2 px-3 py-1 rounded-full bg-green-100 text-green-800">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<span className="text-sm font-medium">稼働中</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
<div className="tabs">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
className={`tab ${activeTab === 'episodes' ? 'active' : ''}`}
|
||||||
className="md:hidden p-2 rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors"
|
onClick={() => setActiveTab('episodes')}
|
||||||
aria-label="メニューを開く"
|
>
|
||||||
></button>
|
エピソード一覧
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</div>
|
className={`tab ${activeTab === 'feeds' ? 'active' : ''}`}
|
||||||
</header>
|
onClick={() => setActiveTab('feeds')}
|
||||||
|
>
|
||||||
|
フィード管理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
<div className="content">
|
||||||
<nav
|
{activeTab === 'episodes' && <EpisodeList />}
|
||||||
className={`bg-white border-b border-gray-200 ${isMenuOpen ? "block" : "hidden md:block"}`}
|
{activeTab === 'feeds' && <FeedManager />}
|
||||||
>
|
</div>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
</div>
|
||||||
<div className="flex flex-col md:flex-row">
|
)
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(tab.id);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className={`flex items-center space-x-3 py-3 px-4 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-600 hover:text-gray-900 border-b-2 border-transparent"
|
|
||||||
}`}
|
|
||||||
aria-current={activeTab === tab.id ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<span role="img" aria-hidden="true">
|
|
||||||
{tab.icon}
|
|
||||||
</span>
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex-1">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
{activeTab === "dashboard" && <Dashboard />}
|
|
||||||
{activeTab === "episodes" && <EpisodePlayer />}
|
|
||||||
{activeTab === "feeds" && <FeedManager />}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="mt-auto bg-white border-t border-gray-200">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-500 text-sm">
|
|
||||||
© 2025 Voice RSS Summary. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App
|
@ -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;
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import "./globals.css";
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="ja">
|
|
||||||
<body>
|
|
||||||
<div className="container">
|
|
||||||
<header className="py-4 border-b">
|
|
||||||
<h1 className="text-2xl font-bold">ポッドキャスト管理画面</h1>
|
|
||||||
</header>
|
|
||||||
<main className="py-6">{children}</main>
|
|
||||||
<footer className="py-4 border-t text-center text-gray-500">
|
|
||||||
<p>© 2025 Podcast Generator</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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 (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="glass-effect border-b border-white/20 sticky top-0 z-50 backdrop-blur-xl">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between items-center py-6">
|
|
||||||
<div className="flex items-center space-x-4 animate-fadeIn">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold gradient-text">
|
|
||||||
Voice RSS Summary
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-slate-600 font-medium">
|
|
||||||
AI音声ポッドキャスト自動生成システム
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:flex items-center space-x-6">
|
|
||||||
<div className="flex items-center space-x-3 px-4 py-2 rounded-full bg-green-50 border border-green-200">
|
|
||||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-lg"></div>
|
|
||||||
<span className="text-sm font-semibold text-green-700">
|
|
||||||
システム稼働中
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="glass-effect border-b border-white/20 relative">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{[
|
|
||||||
{ id: "dashboard", label: "ダッシュボード", icon: "📊" },
|
|
||||||
{ id: "episodes", label: "エピソード", icon: "🎧" },
|
|
||||||
{ id: "feeds", label: "フィード管理", icon: "📡" },
|
|
||||||
].map((tab, index) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
|
||||||
className={`relative flex items-center space-x-3 py-4 px-6 font-semibold text-sm rounded-t-xl transition-all duration-300 transform hover:scale-105 ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? "bg-white text-slate-800 shadow-lg border-b-2 border-transparent"
|
|
||||||
: "text-slate-600 hover:text-slate-800 hover:bg-white/50"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
animationDelay: `${index * 0.1}s`,
|
|
||||||
}}
|
|
||||||
aria-current={activeTab === tab.id ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-lg animate-fadeIn"
|
|
||||||
role="img"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
</span>
|
|
||||||
<span className="animate-slideIn">{tab.label}</span>
|
|
||||||
{activeTab === tab.id && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-t-full"
|
|
||||||
style={{ background: "var(--gradient-primary)" }}
|
|
||||||
></div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<div className="space-y-8">
|
|
||||||
{activeTab === "dashboard" && (
|
|
||||||
<div className="animate-fadeIn">
|
|
||||||
<Dashboard />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === "episodes" && (
|
|
||||||
<div className="glass-effect rounded-3xl shadow-2xl p-8 border border-white/20 animate-fadeIn">
|
|
||||||
<div className="flex items-center space-x-4 mb-8">
|
|
||||||
<div
|
|
||||||
className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg"
|
|
||||||
style={{
|
|
||||||
background: "linear-gradient(135deg, #a855f7, #7c3aed)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span role="img" aria-hidden="true" className="text-2xl">
|
|
||||||
🎧
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-slate-800">
|
|
||||||
エピソード管理
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-600 text-sm">
|
|
||||||
ポッドキャストエピソードの再生と管理
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EpisodePlayer />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === "feeds" && (
|
|
||||||
<div className="glass-effect rounded-3xl shadow-2xl p-8 border border-white/20 animate-fadeIn">
|
|
||||||
<div className="flex items-center space-x-4 mb-8">
|
|
||||||
<div
|
|
||||||
className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg"
|
|
||||||
style={{
|
|
||||||
background: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span role="img" aria-hidden="true" className="text-2xl">
|
|
||||||
📡
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-slate-800">
|
|
||||||
フィード管理
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-600 text-sm">
|
|
||||||
RSSフィードの追加と管理
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FeedManager />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="mt-24 relative">
|
|
||||||
<div className="glass-effect border-t border-white/20">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="flex items-center justify-center space-x-3 mb-4">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
|
||||||
style={{ background: "var(--gradient-primary)" }}
|
|
||||||
></div>
|
|
||||||
<span className="text-lg font-bold gradient-text">
|
|
||||||
Voice RSS Summary
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-600 text-sm font-medium max-w-2xl mx-auto leading-relaxed">
|
|
||||||
AI技術により最新のニュースを音声でお届けします。
|
|
||||||
<br />
|
|
||||||
自動化されたポッドキャスト生成で、いつでもどこでも情報をキャッチアップ。
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 pt-6 border-t border-white/20">
|
|
||||||
<p className="text-slate-500 text-xs">
|
|
||||||
© 2025 Voice RSS Summary. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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<Stats | null>(null);
|
|
||||||
const [recentEpisodes, setRecentEpisodes] = useState<RecentEpisode[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
||||||
<span className="text-gray-600">読み込み中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">総フィード数</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900 mt-2">
|
|
||||||
{stats?.totalFeeds || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-blue-600">
|
|
||||||
<span className="text-sm">📡</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">
|
|
||||||
アクティブフィード
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900 mt-2">
|
|
||||||
{stats?.activeFeeds || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-green-600">
|
|
||||||
<span className="text-sm">✅</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">
|
|
||||||
総エピソード数
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900 mt-2">
|
|
||||||
{stats?.totalEpisodes || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-purple-600">
|
|
||||||
<span className="text-sm">🎧</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">最終更新</p>
|
|
||||||
<p className="text-lg font-bold text-gray-900 mt-2">
|
|
||||||
{stats?.lastUpdated
|
|
||||||
? new Date(stats.lastUpdated).toLocaleDateString("ja-JP")
|
|
||||||
: "未取得"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-orange-600">
|
|
||||||
<span className="text-sm">🕒</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Cards */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Manual Batch Execution */}
|
|
||||||
<div className="bg-blue-600 rounded-lg shadow p-6 text-white">
|
|
||||||
<h3 className="text-lg font-semibold mb-2">手動バッチ実行</h3>
|
|
||||||
<p className="text-blue-100 text-sm mb-4">
|
|
||||||
新しい記事をすぐにチェックしてポッドキャストを生成します。
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={triggerBatchProcess}
|
|
||||||
className="bg-white/20 hover:bg-white/30 text-white font-medium py-2 px-4 rounded transition-colors"
|
|
||||||
>
|
|
||||||
実行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Status */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
システム状態
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center space-x-3 p-3 rounded-lg bg-green-50">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-green-800">
|
|
||||||
自動バッチ処理
|
|
||||||
</span>
|
|
||||||
<p className="text-xs text-green-600">6時間間隔で実行中</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 p-3 rounded-lg bg-blue-50">
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-blue-800">
|
|
||||||
AI音声生成
|
|
||||||
</span>
|
|
||||||
<p className="text-xs text-blue-600">VOICEVOX連携済み</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Episodes */}
|
|
||||||
<div className="bg-white rounded-lg shadow border border-gray-200">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
最新エピソード
|
|
||||||
</h3>
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{recentEpisodes.length} エピソード
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{recentEpisodes.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-4xl mb-4">🎧</div>
|
|
||||||
<h4 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
まだエピソードがありません
|
|
||||||
</h4>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
フィードを追加してバッチ処理を実行してください。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{recentEpisodes.map((episode) => (
|
|
||||||
<div
|
|
||||||
key={episode.id}
|
|
||||||
className="flex items-start space-x-4 p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<span className="text-white text-xs">🎵</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 line-clamp-2">
|
|
||||||
{episode.title}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 text-xs text-gray-500 mt-1">
|
|
||||||
<span>{episode.feed?.title}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
|
||||||
{new Date(episode.createdAt).toLocaleDateString(
|
|
||||||
"ja-JP",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{episode.article && (
|
|
||||||
<a
|
|
||||||
href={episode.article.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center space-x-1 text-xs text-blue-600 hover:text-blue-800 mt-2"
|
|
||||||
>
|
|
||||||
<span>元記事を読む</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">
|
|
||||||
生成済み
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
138
frontend/src/components/EpisodeList.tsx
Normal file
138
frontend/src/components/EpisodeList.tsx
Normal file
@ -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<Episode[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [currentAudio, setCurrentAudio] = useState<string | null>(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 <div className="loading">読み込み中...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="error">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>エピソードがありません</p>
|
||||||
|
<p>フィード管理でRSSフィードを追加してください</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2>エピソード一覧 ({episodes.length}件)</h2>
|
||||||
|
<button className="btn btn-secondary" onClick={fetchEpisodes}>
|
||||||
|
更新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '40%' }}>タイトル</th>
|
||||||
|
<th style={{ width: '20%' }}>フィード</th>
|
||||||
|
<th style={{ width: '20%' }}>作成日時</th>
|
||||||
|
<th style={{ width: '20%' }}>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{episodes.map((episode) => (
|
||||||
|
<tr key={episode.id}>
|
||||||
|
<td>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<strong>{episode.title}</strong>
|
||||||
|
</div>
|
||||||
|
{episode.article?.link && (
|
||||||
|
<a
|
||||||
|
href={episode.article.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: '12px', color: '#666' }}
|
||||||
|
>
|
||||||
|
元記事を見る
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{episode.feed?.title || '不明'}</td>
|
||||||
|
<td>{formatDate(episode.createdAt)}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => playAudio(episode.audioPath)}
|
||||||
|
style={{ marginBottom: '8px' }}
|
||||||
|
>
|
||||||
|
再生
|
||||||
|
</button>
|
||||||
|
{currentAudio === episode.audioPath && (
|
||||||
|
<div>
|
||||||
|
<audio
|
||||||
|
id={episode.audioPath}
|
||||||
|
controls
|
||||||
|
className="audio-player"
|
||||||
|
src={`/podcast_audio/${episode.audioPath}`}
|
||||||
|
onEnded={() => setCurrentAudio(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EpisodeList
|
@ -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<Episode[]>([]);
|
|
||||||
const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(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<string | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(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<HTMLInputElement>) => {
|
|
||||||
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 (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
|
||||||
<span className="text-gray-600">読み込み中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="エピソードを検索..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audio Player */}
|
|
||||||
{selectedEpisode && (
|
|
||||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl p-6 border border-purple-100">
|
|
||||||
<div className="flex items-center space-x-4 mb-4">
|
|
||||||
<div className="w-12 h-12 bg-gradient-to-r from-purple-500 to-blue-600 rounded-full flex items-center justify-center text-white">
|
|
||||||
<span role="img" aria-hidden="true" className="text-xl">
|
|
||||||
🎵
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
|
||||||
{selectedEpisode.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{selectedEpisode.feed.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handlePlay(selectedEpisode)}
|
|
||||||
className="w-12 h-12 bg-white rounded-full shadow-md flex items-center justify-center hover:shadow-lg transition-shadow duration-200"
|
|
||||||
aria-label={isPlaying ? "一時停止" : "再生"}
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={duration || 0}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={handleSeek}
|
|
||||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
|
|
||||||
aria-label="再生位置"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-gray-500">
|
|
||||||
<span>{formatTime(currentTime)}</span>
|
|
||||||
<span>{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Episode Info */}
|
|
||||||
<div className="mt-4 space-y-2 text-sm">
|
|
||||||
{selectedEpisode.description && (
|
|
||||||
<p className="text-gray-700">{selectedEpisode.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center space-x-4 text-gray-500">
|
|
||||||
<span>
|
|
||||||
🗓️{" "}
|
|
||||||
{new Date(selectedEpisode.createdAt).toLocaleDateString(
|
|
||||||
"ja-JP",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span>💾 {formatFileSize(selectedEpisode.fileSize)}</span>
|
|
||||||
{selectedEpisode.article.link && (
|
|
||||||
<a
|
|
||||||
href={selectedEpisode.article.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
📄 元記事
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={`/podcast_audio/${selectedEpisode.audioPath}`}
|
|
||||||
onPlay={() => setIsPlaying(true)}
|
|
||||||
onPause={() => setIsPlaying(false)}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Episodes List */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
エピソード一覧
|
|
||||||
</h3>
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{filteredEpisodes.length} エピソード
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredEpisodes.length === 0 ? (
|
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
|
||||||
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<span role="img" aria-hidden="true" className="text-2xl">
|
|
||||||
🎧
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
{searchTerm ? "検索結果がありません" : "エピソードがありません"}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{searchTerm
|
|
||||||
? "別のキーワードで検索してみてください"
|
|
||||||
: "フィードを追加してバッチ処理を実行してください"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{filteredEpisodes.map((episode) => (
|
|
||||||
<div
|
|
||||||
key={episode.id}
|
|
||||||
className={`border rounded-xl p-4 transition-all duration-200 cursor-pointer ${
|
|
||||||
selectedEpisode?.id === episode.id
|
|
||||||
? "border-purple-300 bg-purple-50 shadow-md"
|
|
||||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
|
||||||
}`}
|
|
||||||
onClick={() => handlePlay(episode)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handlePlay(episode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label={`エピソード: ${episode.title}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="text-base font-medium text-gray-900 mb-1 line-clamp-2">
|
|
||||||
{episode.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-2 line-clamp-1">
|
|
||||||
{episode.feed.title}
|
|
||||||
</p>
|
|
||||||
{episode.description && (
|
|
||||||
<p className="text-sm text-gray-500 mb-2 line-clamp-2">
|
|
||||||
{episode.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
|
||||||
<span>
|
|
||||||
📅{" "}
|
|
||||||
{new Date(episode.createdAt).toLocaleDateString(
|
|
||||||
"ja-JP",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span>💾 {formatFileSize(episode.fileSize)}</span>
|
|
||||||
{episode.article.link && (
|
|
||||||
<a
|
|
||||||
href={episode.article.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:text-blue-800"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
📄 元記事
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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<FeedItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="glass-effect rounded-3xl p-6 border border-white/20 animate-pulse"
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="w-16 h-16 bg-slate-200 rounded-2xl"></div>
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
<div className="h-5 bg-slate-200 rounded-lg w-3/4"></div>
|
|
||||||
<div className="h-4 bg-slate-200 rounded-lg w-1/2"></div>
|
|
||||||
<div className="h-4 bg-slate-200 rounded-lg w-full"></div>
|
|
||||||
<div className="h-4 bg-slate-200 rounded-lg w-2/3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="glass-effect rounded-3xl p-8 border border-red-200 bg-red-50">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="w-12 h-12 rounded-2xl bg-red-100 flex items-center justify-center">
|
|
||||||
⚠️
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold text-red-800">
|
|
||||||
エラーが発生しました
|
|
||||||
</h3>
|
|
||||||
<p className="text-red-700">{error}</p>
|
|
||||||
<button onClick={fetchFeeds} className="mt-3 btn-primary text-sm">
|
|
||||||
再読み込み
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Sort Controls */}
|
|
||||||
<div className="glass-effect rounded-2xl p-4 border border-white/20">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-sm font-semibold text-slate-700">
|
|
||||||
並び替え:
|
|
||||||
</span>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSort("date")}
|
|
||||||
className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
|
||||||
sortBy === "date"
|
|
||||||
? "bg-blue-100 text-blue-800 border border-blue-200"
|
|
||||||
: "text-slate-600 hover:text-slate-800 hover:bg-slate-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>日時</span>
|
|
||||||
{sortBy === "date" && (
|
|
||||||
<span>{sortOrder === "asc" ? "↑" : "↓"}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSort("title")}
|
|
||||||
className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
|
||||||
sortBy === "title"
|
|
||||||
? "bg-blue-100 text-blue-800 border border-blue-200"
|
|
||||||
: "text-slate-600 hover:text-slate-800 hover:bg-slate-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>タイトル</span>
|
|
||||||
{sortBy === "title" && (
|
|
||||||
<span>{sortOrder === "asc" ? "↑" : "↓"}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-500 ml-auto">
|
|
||||||
{filteredAndSortedFeeds.length} / {feeds.length} 件表示中
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feed Cards */}
|
|
||||||
{filteredAndSortedFeeds.length === 0 ? (
|
|
||||||
<div className="text-center py-20">
|
|
||||||
<div
|
|
||||||
className="w-24 h-24 rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-lg"
|
|
||||||
style={{ background: "linear-gradient(135deg, #e2e8f0, #cbd5e1)" }}
|
|
||||||
>
|
|
||||||
<span role="img" aria-hidden="true" className="text-4xl">
|
|
||||||
📰
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold text-slate-700 mb-2">
|
|
||||||
{searchTerm || categoryFilter
|
|
||||||
? "検索結果がありません"
|
|
||||||
: "フィードがありません"}
|
|
||||||
</h3>
|
|
||||||
<p className="text-slate-500 max-w-md mx-auto">
|
|
||||||
{searchTerm || categoryFilter
|
|
||||||
? "別のキーワードやカテゴリで検索してみてください"
|
|
||||||
: "RSSフィードを追加してバッチ処理を実行してください"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredAndSortedFeeds.map((feed, index) => (
|
|
||||||
<article
|
|
||||||
key={feed.id}
|
|
||||||
className="group glass-effect rounded-3xl border border-white/20 hover:border-white/40 hover:shadow-2xl transition-all duration-300 overflow-hidden"
|
|
||||||
style={{
|
|
||||||
animationDelay: `${index * 0.05}s`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-start space-x-5">
|
|
||||||
{/* Article Icon */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
className="w-16 h-16 rounded-2xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-300 group-hover:scale-110"
|
|
||||||
style={{ background: "var(--gradient-primary)" }}
|
|
||||||
>
|
|
||||||
<span role="img" aria-hidden="true" className="text-2xl">
|
|
||||||
📰
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Article Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-bold text-slate-800 line-clamp-2 group-hover:text-slate-900 transition-colors duration-200">
|
|
||||||
{feed.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Meta Info */}
|
|
||||||
<div className="flex items-center space-x-3 mt-2 text-sm text-slate-600">
|
|
||||||
{feed.source?.title && (
|
|
||||||
<span className="font-medium">
|
|
||||||
{feed.source.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-slate-400">•</span>
|
|
||||||
<span>{formatDate(feed.pubDate)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category Badge */}
|
|
||||||
{feed.category && (
|
|
||||||
<span className="ml-4 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200">
|
|
||||||
{feed.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Snippet */}
|
|
||||||
{feed.contentSnippet && (
|
|
||||||
<p className="text-slate-600 leading-relaxed line-clamp-3 mb-4">
|
|
||||||
{feed.contentSnippet}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<a
|
|
||||||
href={feed.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm font-semibold text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
元記事を読む →
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="text-xs text-slate-400">#{index + 1}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,263 +1,213 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from 'react'
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface Feed {
|
interface Feed {
|
||||||
id: string;
|
id: string
|
||||||
url: string;
|
url: string
|
||||||
title?: string;
|
title?: string
|
||||||
description?: string;
|
description?: string
|
||||||
lastUpdated?: string;
|
active: boolean
|
||||||
createdAt: string;
|
lastUpdated?: string
|
||||||
active: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedManager() {
|
function FeedManager() {
|
||||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
const [feeds, setFeeds] = useState<Feed[]>([])
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [newFeedUrl, setNewFeedUrl] = useState("");
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
const [addingFeed, setAddingFeed] = useState(false);
|
const [newFeedUrl, setNewFeedUrl] = useState('')
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeeds();
|
fetchFeeds()
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const fetchFeeds = async () => {
|
const fetchFeeds = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
const response = await fetch("/api/feeds");
|
const response = await fetch('/api/feeds')
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('フィードの取得に失敗しました')
|
||||||
throw new Error("フィードの取得に失敗しました");
|
const data = await response.json()
|
||||||
}
|
setFeeds(data)
|
||||||
const data = await response.json();
|
|
||||||
setFeeds(data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const addFeed = async (e: React.FormEvent) => {
|
const addFeed = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
|
if (!newFeedUrl.trim()) return
|
||||||
|
|
||||||
if (!newFeedUrl.trim()) {
|
try {
|
||||||
alert("フィードURLを入力してください");
|
setAdding(true)
|
||||||
return;
|
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")) {
|
const deleteFeed = async (feedId: string) => {
|
||||||
alert("有効なURLを入力してください");
|
if (!confirm('このフィードを削除しますか?関連するエピソードも削除されます。')) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setAddingFeed(true);
|
setError(null)
|
||||||
const response = await fetch("/api/feeds", {
|
setSuccess(null)
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ feedUrl: newFeedUrl }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
const response = await fetch(`/api/feeds/${feedId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
if (result.result === "EXISTS") {
|
const errorData = await response.json()
|
||||||
alert("このフィードは既に登録されています");
|
throw new Error(errorData.error || 'フィードの削除に失敗しました')
|
||||||
} else {
|
|
||||||
alert("フィードが正常に追加されました");
|
|
||||||
setNewFeedUrl("");
|
|
||||||
fetchFeeds(); // Refresh the list
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert(result.error || "フィードの追加に失敗しました");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSuccess('フィードを削除しました')
|
||||||
|
await fetchFeeds()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("エラーが発生しました");
|
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||||
console.error("Feed addition error:", err);
|
|
||||||
} finally {
|
|
||||||
setAddingFeed(false);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return <div className="loading">読み込み中...</div>
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
||||||
<span className="text-gray-600">読み込み中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
{/* Add New Feed Form */}
|
{error && <div className="error">{error}</div>}
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl p-6 border border-blue-100">
|
{success && <div className="success">{success}</div>}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
新しいフィードを追加
|
<div style={{ marginBottom: '30px' }}>
|
||||||
</h3>
|
<h2>新しいフィードを追加</h2>
|
||||||
<form onSubmit={addFeed} className="space-y-4">
|
<form onSubmit={addFeed}>
|
||||||
<div>
|
<div className="form-group">
|
||||||
<label
|
<label className="form-label">RSS フィード URL</label>
|
||||||
htmlFor="feedUrl"
|
<input
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
type="url"
|
||||||
>
|
className="form-input"
|
||||||
RSS フィード URL
|
value={newFeedUrl}
|
||||||
</label>
|
onChange={(e) => setNewFeedUrl(e.target.value)}
|
||||||
<div className="flex space-x-3">
|
placeholder="https://example.com/feed.xml"
|
||||||
<input
|
required
|
||||||
type="url"
|
/>
|
||||||
id="feedUrl"
|
|
||||||
value={newFeedUrl}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={addingFeed || !newFeedUrl.trim()}
|
|
||||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{addingFeed ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
<span>追加中...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"追加"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p id="feedUrl-help" className="text-xs text-gray-500 mt-2">
|
|
||||||
RSS または Atom フィードの URL を入力してください
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={adding}
|
||||||
|
>
|
||||||
|
{adding ? '追加中...' : 'フィードを追加'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Feeds List */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h2>登録済みフィード ({feeds.length}件)</h2>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
登録済みフィード
|
|
||||||
</h3>
|
|
||||||
<span className="text-sm text-gray-500">{feeds.length} フィード</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{feeds.length === 0 ? (
|
{feeds.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
<div className="empty-state">
|
||||||
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
<p>登録されているフィードがありません</p>
|
||||||
<span role="img" aria-hidden="true" className="text-2xl">
|
|
||||||
📡
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
フィードがありません
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
上のフォームから RSS フィードを追加してください
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div>
|
||||||
{feeds.map((feed) => (
|
{feeds.map((feed) => (
|
||||||
<div
|
<div key={feed.id} className="feed-item">
|
||||||
key={feed.id}
|
<div className="feed-info">
|
||||||
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-md transition-shadow duration-200"
|
<div className="feed-title">
|
||||||
>
|
{feed.title || 'タイトル不明'}
|
||||||
<div className="flex items-start justify-between">
|
{!feed.active && (
|
||||||
<div className="flex-1 min-w-0">
|
<span style={{
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
marginLeft: '8px',
|
||||||
<div
|
padding: '2px 6px',
|
||||||
className={`w-3 h-3 rounded-full ${feed.active ? "bg-green-400" : "bg-gray-400"}`}
|
backgroundColor: '#dc3545',
|
||||||
></div>
|
color: 'white',
|
||||||
<h4 className="text-lg font-medium text-gray-900 truncate">
|
fontSize: '12px',
|
||||||
{feed.title || "タイトル未取得"}
|
borderRadius: '3px'
|
||||||
</h4>
|
}}>
|
||||||
</div>
|
無効
|
||||||
|
</span>
|
||||||
{feed.description && (
|
|
||||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
|
||||||
{feed.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-xs font-medium text-gray-500">
|
|
||||||
URL:
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={feed.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800 truncate max-w-xs"
|
|
||||||
title={feed.url}
|
|
||||||
>
|
|
||||||
{feed.url}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
|
||||||
<span>
|
|
||||||
追加日:{" "}
|
|
||||||
{new Date(feed.createdAt).toLocaleDateString("ja-JP")}
|
|
||||||
</span>
|
|
||||||
{feed.lastUpdated && (
|
|
||||||
<span>
|
|
||||||
最終更新:{" "}
|
|
||||||
{new Date(feed.lastUpdated).toLocaleDateString(
|
|
||||||
"ja-JP",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="feed-url">{feed.url}</div>
|
||||||
<div className="flex items-center space-x-2 ml-4">
|
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
<span
|
最終更新: {formatDate(feed.lastUpdated)}
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
feed.active
|
|
||||||
? "bg-green-100 text-green-800"
|
|
||||||
: "bg-gray-100 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{feed.active ? "アクティブ" : "無効"}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Future: Add edit/delete buttons here */}
|
|
||||||
<button
|
|
||||||
className="text-gray-400 hover:text-gray-600 p-1"
|
|
||||||
title="設定"
|
|
||||||
aria-label={`${feed.title || feed.url}の設定`}
|
|
||||||
></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="feed-actions">
|
||||||
|
<button
|
||||||
|
className={`btn ${feed.active ? 'btn-secondary' : 'btn-primary'}`}
|
||||||
|
onClick={() => toggleFeed(feed.id, !feed.active)}
|
||||||
|
>
|
||||||
|
{feed.active ? '無効化' : '有効化'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => deleteFeed(feed.id)}
|
||||||
|
>
|
||||||
|
削除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default FeedManager
|
@ -1,10 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from "./App.tsx";
|
import App from './App.tsx'
|
||||||
import React from "react";
|
import './styles.css'
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
)
|
225
frontend/src/styles.css
Normal file
225
frontend/src/styles.css
Normal file
@ -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;
|
||||||
|
}
|
13
schema.sql
13
schema.sql
@ -42,9 +42,22 @@ CREATE TABLE IF NOT EXISTS processed_feed_items (
|
|||||||
PRIMARY KEY(feed_url, item_id)
|
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 indexes for better performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
|
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_pub_date ON articles(pub_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
|
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_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);
|
||||||
|
@ -192,6 +192,9 @@ async function processUnprocessedArticles(): Promise<void> {
|
|||||||
console.log("🎧 Processing unprocessed articles...");
|
console.log("🎧 Processing unprocessed articles...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Process retry queue first
|
||||||
|
await processRetryQueue();
|
||||||
|
|
||||||
// Get unprocessed articles (limit to prevent overwhelming)
|
// Get unprocessed articles (limit to prevent overwhelming)
|
||||||
const unprocessedArticles = await getUnprocessedArticles(
|
const unprocessedArticles = await getUnprocessedArticles(
|
||||||
Number.parseInt(import.meta.env["LIMIT_UNPROCESSED_ARTICLES"] || "10"),
|
Number.parseInt(import.meta.env["LIMIT_UNPROCESSED_ARTICLES"] || "10"),
|
||||||
@ -204,12 +207,15 @@ 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
|
||||||
|
const successfullyGeneratedArticles: string[] = [];
|
||||||
|
|
||||||
for (const article of unprocessedArticles) {
|
for (const article of unprocessedArticles) {
|
||||||
try {
|
try {
|
||||||
await generatePodcastForArticle(article);
|
await generatePodcastForArticle(article);
|
||||||
await markArticleAsProcessed(article.id);
|
await markArticleAsProcessed(article.id);
|
||||||
console.log(`✅ Podcast generated for: ${article.title}`);
|
console.log(`✅ Podcast generated for: ${article.title}`);
|
||||||
await updatePodcastRSS(); // Update RSS after each article
|
successfullyGeneratedArticles.push(article.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ Failed to generate podcast for article: ${article.title}`,
|
`❌ Failed to generate podcast for article: ${article.title}`,
|
||||||
@ -218,12 +224,68 @@ async function processUnprocessedArticles(): Promise<void> {
|
|||||||
// Don't mark as processed if generation failed
|
// 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) {
|
} catch (error) {
|
||||||
console.error("💥 Error processing unprocessed articles:", error);
|
console.error("💥 Error processing unprocessed articles:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process retry queue for failed TTS generation
|
||||||
|
*/
|
||||||
|
async function processRetryQueue(): Promise<void> {
|
||||||
|
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
|
* Generate podcast for a single article
|
||||||
*/
|
*/
|
||||||
|
82
server.ts
82
server.ts
@ -107,6 +107,88 @@ app.get("/", serveIndex);
|
|||||||
|
|
||||||
app.get("/index.html", 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
|
// Catch-all for SPA routing
|
||||||
app.get("*", serveIndex);
|
app.get("*", serveIndex);
|
||||||
|
|
||||||
|
@ -60,11 +60,23 @@ function initializeDatabase(): Database {
|
|||||||
PRIMARY KEY(feed_url, item_id)
|
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_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_pub_date ON articles(pub_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
|
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_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;
|
return db;
|
||||||
}
|
}
|
||||||
@ -506,6 +518,89 @@ export async function fetchEpisodesWithArticles(): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TTS Queue management functions
|
||||||
|
export interface TTSQueueItem {
|
||||||
|
id: string;
|
||||||
|
itemId: string;
|
||||||
|
scriptText: string;
|
||||||
|
retryCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
lastAttemptedAt?: string;
|
||||||
|
status: 'pending' | 'processing' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToQueue(
|
||||||
|
itemId: string,
|
||||||
|
scriptText: string,
|
||||||
|
retryCount: number = 0,
|
||||||
|
): Promise<string> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
"INSERT INTO tts_queue (id, item_id, script_text, retry_count, created_at, status) VALUES (?, ?, ?, ?, ?, 'pending')",
|
||||||
|
);
|
||||||
|
stmt.run(id, itemId, scriptText, retryCount, createdAt);
|
||||||
|
console.log(`TTS queue に追加: ${itemId} (試行回数: ${retryCount})`);
|
||||||
|
return id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding to TTS queue:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueueItems(limit: number = 10): Promise<TTSQueueItem[]> {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT * FROM tts_queue
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
const rows = stmt.all(limit) as any[];
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
itemId: row.item_id,
|
||||||
|
scriptText: row.script_text,
|
||||||
|
retryCount: row.retry_count,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
lastAttemptedAt: row.last_attempted_at,
|
||||||
|
status: row.status,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting queue items:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQueueItemStatus(
|
||||||
|
queueId: string,
|
||||||
|
status: 'pending' | 'processing' | 'failed',
|
||||||
|
lastAttemptedAt?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
"UPDATE tts_queue SET status = ?, last_attempted_at = ? WHERE id = ?",
|
||||||
|
);
|
||||||
|
stmt.run(status, lastAttemptedAt || new Date().toISOString(), queueId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating queue item status:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromQueue(queueId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare("DELETE FROM tts_queue WHERE id = ?");
|
||||||
|
stmt.run(queueId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error removing from queue:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeDatabase(): void {
|
export function closeDatabase(): void {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
@ -45,9 +45,22 @@ function createItemXml(episode: Episode): string {
|
|||||||
export async function updatePodcastRSS(): Promise<void> {
|
export async function updatePodcastRSS(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const episodes: Episode[] = await fetchAllEpisodes();
|
const episodes: Episode[] = await fetchAllEpisodes();
|
||||||
const lastBuildDate = new Date().toUTCString();
|
|
||||||
|
|
||||||
const itemsXml = episodes.map(createItemXml).join("\n");
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
const outputPath = path.join(config.paths.publicDir, "podcast.xml");
|
||||||
|
|
||||||
// Create RSS XML content
|
// Create RSS XML content
|
||||||
@ -69,7 +82,7 @@ export async function updatePodcastRSS(): Promise<void> {
|
|||||||
await fs.mkdir(dirname(outputPath), { recursive: true });
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
||||||
await fs.writeFile(outputPath, rssXml);
|
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) {
|
} catch (error) {
|
||||||
console.error("Error updating podcast RSS:", error);
|
console.error("Error updating podcast RSS:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
185
services/tts.ts
185
services/tts.ts
@ -15,6 +15,7 @@ const defaultVoiceStyle: VoiceStyle = {
|
|||||||
export async function generateTTS(
|
export async function generateTTS(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
scriptText: string,
|
scriptText: string,
|
||||||
|
retryCount: number = 0,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!itemId || itemId.trim() === "") {
|
if (!itemId || itemId.trim() === "") {
|
||||||
throw new Error("Item ID is required for TTS generation");
|
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");
|
throw new Error("Script text is required for TTS generation");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`TTS生成開始: ${itemId}`);
|
const maxRetries = 2;
|
||||||
const encodedText = encodeURIComponent(scriptText);
|
|
||||||
|
|
||||||
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
try {
|
||||||
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}/${maxRetries + 1})`);
|
||||||
|
const encodedText = encodeURIComponent(scriptText);
|
||||||
|
|
||||||
const queryResponse = await fetch(queryUrl, {
|
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
||||||
method: "POST",
|
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!queryResponse.ok) {
|
const queryResponse = await fetch(queryUrl, {
|
||||||
const errorText = await queryResponse.text();
|
method: "POST",
|
||||||
throw new Error(
|
headers: {
|
||||||
`VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`,
|
"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) {
|
||||||
|
console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
|
||||||
|
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
const { addToQueue } = await import("../services/database.js");
|
||||||
|
await addToQueue(itemId, scriptText, retryCount + 1);
|
||||||
|
throw new Error(`TTS generation failed, added to retry queue: ${error}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioQuery = await queryResponse.json();
|
|
||||||
|
|
||||||
console.log(`音声合成開始: ${itemId}`);
|
|
||||||
const audioResponse = await fetch(synthesisUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(audioQuery),
|
|
||||||
signal: AbortSignal.timeout(600000), // 10分のタイムアウト
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!audioResponse.ok) {
|
|
||||||
const errorText = await audioResponse.text();
|
|
||||||
console.error(`音声合成失敗: ${itemId}`);
|
|
||||||
throw new Error(
|
|
||||||
`VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioArrayBuffer = await audioResponse.arrayBuffer();
|
|
||||||
const audioBuffer = Buffer.from(audioArrayBuffer);
|
|
||||||
|
|
||||||
// 出力ディレクトリの準備
|
|
||||||
const outputDir = config.paths.podcastAudioDir;
|
|
||||||
if (!fs.existsSync(outputDir)) {
|
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
|
|
||||||
const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
|
|
||||||
|
|
||||||
console.log(`WAVファイル保存開始: ${wavFilePath}`);
|
|
||||||
fs.writeFileSync(wavFilePath, audioBuffer);
|
|
||||||
console.log(`WAVファイル保存完了: ${wavFilePath}`);
|
|
||||||
|
|
||||||
console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
|
|
||||||
|
|
||||||
const ffmpegCmd = ffmpegPath || "ffmpeg";
|
|
||||||
const result = Bun.spawnSync({
|
|
||||||
cmd: [
|
|
||||||
ffmpegCmd,
|
|
||||||
"-i",
|
|
||||||
wavFilePath,
|
|
||||||
"-codec:a",
|
|
||||||
"libmp3lame",
|
|
||||||
"-qscale:a",
|
|
||||||
"2",
|
|
||||||
"-y", // Overwrite output file
|
|
||||||
mp3FilePath,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
const stderr = result.stderr
|
|
||||||
? new TextDecoder().decode(result.stderr)
|
|
||||||
: "Unknown error";
|
|
||||||
throw new Error(`FFmpeg conversion failed: ${stderr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wavファイルを削除
|
|
||||||
if (fs.existsSync(wavFilePath)) {
|
|
||||||
fs.unlinkSync(wavFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`TTS生成完了: ${itemId}`);
|
|
||||||
|
|
||||||
return path.basename(mp3FilePath);
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user