Apply formatting
This commit is contained in:
@ -13,6 +13,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- **Admin panel development**: `bun run dev:admin`
|
- **Admin panel development**: `bun run dev:admin`
|
||||||
- **Manual batch process**: `bun run scripts/fetch_and_generate.ts`
|
- **Manual batch process**: `bun run scripts/fetch_and_generate.ts`
|
||||||
- **Type checking**: `bunx tsc --noEmit`
|
- **Type checking**: `bunx tsc --noEmit`
|
||||||
|
- **Format code**: `bun run format`
|
||||||
|
- **Check format**: `bun run format:check`
|
||||||
|
- **Lint code**: `bun run lint`
|
||||||
|
- **Fix lint issues**: `bun run lint:fix`
|
||||||
|
- **Check all (format + lint)**: `bun run check`
|
||||||
|
- **Fix all issues**: `bun run check:fix`
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
|
@ -18,4 +18,4 @@
|
|||||||
"typescript": "^5.0.2",
|
"typescript": "^5.0.2",
|
||||||
"vite": "^4.4.5"
|
"vite": "^4.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface Feed {
|
interface Feed {
|
||||||
id: string;
|
id: string;
|
||||||
@ -38,7 +38,7 @@ interface FeedRequest {
|
|||||||
url: string;
|
url: string;
|
||||||
requestedBy?: string;
|
requestedBy?: string;
|
||||||
requestMessage?: string;
|
requestMessage?: string;
|
||||||
status: 'pending' | 'approved' | 'rejected';
|
status: "pending" | "approved" | "rejected";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
reviewedAt?: string;
|
reviewedAt?: string;
|
||||||
reviewedBy?: string;
|
reviewedBy?: string;
|
||||||
@ -53,10 +53,16 @@ function App() {
|
|||||||
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 [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [newFeedUrl, setNewFeedUrl] = useState('');
|
const [newFeedUrl, setNewFeedUrl] = useState("");
|
||||||
const [requestFilter, setRequestFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
|
const [requestFilter, setRequestFilter] = useState<
|
||||||
const [approvalNotes, setApprovalNotes] = useState<{[key: string]: string}>({});
|
"all" | "pending" | "approved" | "rejected"
|
||||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'feeds' | 'env' | 'batch' | 'requests'>('dashboard');
|
>("all");
|
||||||
|
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
"dashboard" | "feeds" | "env" | "batch" | "requests"
|
||||||
|
>("dashboard");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@ -66,21 +72,21 @@ function App() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [feedsRes, statsRes, envRes, requestsRes] = await Promise.all([
|
const [feedsRes, statsRes, envRes, requestsRes] = await Promise.all([
|
||||||
fetch('/api/admin/feeds'),
|
fetch("/api/admin/feeds"),
|
||||||
fetch('/api/admin/stats'),
|
fetch("/api/admin/stats"),
|
||||||
fetch('/api/admin/env'),
|
fetch("/api/admin/env"),
|
||||||
fetch('/api/admin/feed-requests')
|
fetch("/api/admin/feed-requests"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok) {
|
if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok) {
|
||||||
throw new Error('Failed to load data');
|
throw new Error("Failed to load data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [feedsData, statsData, envData, requestsData] = await Promise.all([
|
const [feedsData, statsData, envData, requestsData] = await Promise.all([
|
||||||
feedsRes.json(),
|
feedsRes.json(),
|
||||||
statsRes.json(),
|
statsRes.json(),
|
||||||
envRes.json(),
|
envRes.json(),
|
||||||
requestsRes.json()
|
requestsRes.json(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setFeeds(feedsData);
|
setFeeds(feedsData);
|
||||||
@ -89,8 +95,8 @@ function App() {
|
|||||||
setFeedRequests(requestsData);
|
setFeedRequests(requestsData);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('データの読み込みに失敗しました');
|
setError("データの読み込みに失敗しました");
|
||||||
console.error('Error loading data:', err);
|
console.error("Error loading data:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -101,35 +107,39 @@ function App() {
|
|||||||
if (!newFeedUrl.trim()) return;
|
if (!newFeedUrl.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/feeds', {
|
const res = await fetch("/api/admin/feeds", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ feedUrl: newFeedUrl })
|
body: JSON.stringify({ feedUrl: newFeedUrl }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setSuccess(data.message);
|
setSuccess(data.message);
|
||||||
setNewFeedUrl('');
|
setNewFeedUrl("");
|
||||||
loadData();
|
loadData();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'フィード追加に失敗しました');
|
setError(data.error || "フィード追加に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('フィード追加に失敗しました');
|
setError("フィード追加に失敗しました");
|
||||||
console.error('Error adding feed:', err);
|
console.error("Error adding feed:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteFeed = async (feedId: string) => {
|
const deleteFeed = async (feedId: string) => {
|
||||||
if (!confirm('本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。')) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
"本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/feeds/${feedId}`, {
|
const res = await fetch(`/api/admin/feeds/${feedId}`, {
|
||||||
method: 'DELETE'
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -138,20 +148,20 @@ function App() {
|
|||||||
setSuccess(data.message);
|
setSuccess(data.message);
|
||||||
loadData();
|
loadData();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'フィード削除に失敗しました');
|
setError(data.error || "フィード削除に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('フィード削除に失敗しました');
|
setError("フィード削除に失敗しました");
|
||||||
console.error('Error deleting feed:', err);
|
console.error("Error deleting feed:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFeed = async (feedId: string, active: boolean) => {
|
const toggleFeed = async (feedId: string, active: boolean) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/feeds/${feedId}/toggle`, {
|
const res = await fetch(`/api/admin/feeds/${feedId}/toggle`, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ active })
|
body: JSON.stringify({ active }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -160,18 +170,18 @@ function App() {
|
|||||||
setSuccess(data.message);
|
setSuccess(data.message);
|
||||||
loadData();
|
loadData();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'フィードステータス変更に失敗しました');
|
setError(data.error || "フィードステータス変更に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('フィードステータス変更に失敗しました');
|
setError("フィードステータス変更に失敗しました");
|
||||||
console.error('Error toggling feed:', err);
|
console.error("Error toggling feed:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerBatch = async () => {
|
const triggerBatch = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/batch/trigger', {
|
const res = await fetch("/api/admin/batch/trigger", {
|
||||||
method: 'POST'
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -180,47 +190,54 @@ function App() {
|
|||||||
setSuccess(data.message);
|
setSuccess(data.message);
|
||||||
loadData(); // Refresh data to update batch status
|
loadData(); // Refresh data to update batch status
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'バッチ処理開始に失敗しました');
|
setError(data.error || "バッチ処理開始に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('バッチ処理開始に失敗しました');
|
setError("バッチ処理開始に失敗しました");
|
||||||
console.error('Error triggering batch:', err);
|
console.error("Error triggering batch:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const forceStopBatch = async () => {
|
const forceStopBatch = async () => {
|
||||||
if (!confirm('実行中のバッチ処理を強制停止しますか?\n\n進行中の処理が中断され、データの整合性に影響が出る可能性があります。')) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
"実行中のバッチ処理を強制停止しますか?\n\n進行中の処理が中断され、データの整合性に影響が出る可能性があります。",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/batch/force-stop', {
|
const res = await fetch("/api/admin/batch/force-stop", {
|
||||||
method: 'POST'
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (data.result === 'STOPPED') {
|
if (data.result === "STOPPED") {
|
||||||
setSuccess(data.message);
|
setSuccess(data.message);
|
||||||
} else if (data.result === 'NO_PROCESS') {
|
} else if (data.result === "NO_PROCESS") {
|
||||||
setSuccess(data.message);
|
setSuccess(data.message);
|
||||||
}
|
}
|
||||||
loadData(); // Refresh data to update batch status
|
loadData(); // Refresh data to update batch status
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'バッチ処理強制停止に失敗しました');
|
setError(data.error || "バッチ処理強制停止に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('バッチ処理強制停止に失敗しました');
|
setError("バッチ処理強制停止に失敗しました");
|
||||||
console.error('Error force stopping batch:', err);
|
console.error("Error force stopping batch:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleBatchScheduler = async (enable: boolean) => {
|
const toggleBatchScheduler = async (enable: boolean) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/batch/${enable ? 'enable' : 'disable'}`, {
|
const res = await fetch(
|
||||||
method: 'POST'
|
`/api/admin/batch/${enable ? "enable" : "disable"}`,
|
||||||
});
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@ -228,61 +245,61 @@ function App() {
|
|||||||
setSuccess(data.message);
|
setSuccess(data.message);
|
||||||
loadData(); // Refresh data to update batch status
|
loadData(); // Refresh data to update batch status
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'バッチスケジューラーの状態変更に失敗しました');
|
setError(data.error || "バッチスケジューラーの状態変更に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('バッチスケジューラーの状態変更に失敗しました');
|
setError("バッチスケジューラーの状態変更に失敗しました");
|
||||||
console.error('Error toggling batch scheduler:', err);
|
console.error("Error toggling batch scheduler:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const approveFeedRequest = async (requestId: string, notes: string) => {
|
const approveFeedRequest = async (requestId: string, notes: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/feed-requests/${requestId}/approve`, {
|
const res = await fetch(`/api/admin/feed-requests/${requestId}/approve`, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ adminNotes: notes })
|
body: JSON.stringify({ adminNotes: notes }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setSuccess(data.message || 'フィードリクエストを承認しました');
|
setSuccess(data.message || "フィードリクエストを承認しました");
|
||||||
setApprovalNotes({ ...approvalNotes, [requestId]: '' });
|
setApprovalNotes({ ...approvalNotes, [requestId]: "" });
|
||||||
loadData();
|
loadData();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'フィードリクエストの承認に失敗しました');
|
setError(data.error || "フィードリクエストの承認に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('フィードリクエストの承認に失敗しました');
|
setError("フィードリクエストの承認に失敗しました");
|
||||||
console.error('Error approving feed request:', err);
|
console.error("Error approving feed request:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectFeedRequest = async (requestId: string, notes: string) => {
|
const rejectFeedRequest = async (requestId: string, notes: string) => {
|
||||||
if (!confirm('このフィードリクエストを拒否しますか?')) {
|
if (!confirm("このフィードリクエストを拒否しますか?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/feed-requests/${requestId}/reject`, {
|
const res = await fetch(`/api/admin/feed-requests/${requestId}/reject`, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ adminNotes: notes })
|
body: JSON.stringify({ adminNotes: notes }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setSuccess(data.message || 'フィードリクエストを拒否しました');
|
setSuccess(data.message || "フィードリクエストを拒否しました");
|
||||||
setApprovalNotes({ ...approvalNotes, [requestId]: '' });
|
setApprovalNotes({ ...approvalNotes, [requestId]: "" });
|
||||||
loadData();
|
loadData();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'フィードリクエストの拒否に失敗しました');
|
setError(data.error || "フィードリクエストの拒否に失敗しました");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('フィードリクエストの拒否に失敗しました');
|
setError("フィードリクエストの拒否に失敗しました");
|
||||||
console.error('Error rejecting feed request:', err);
|
console.error("Error rejecting feed request:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -290,20 +307,26 @@ function App() {
|
|||||||
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
|
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredRequests = feedRequests.filter(request => {
|
const filteredRequests = feedRequests.filter((request) => {
|
||||||
if (requestFilter === 'all') return true;
|
if (requestFilter === "all") return true;
|
||||||
return request.status === requestFilter;
|
return request.status === requestFilter;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="container"><div className="loading">読み込み中...</div></div>;
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="loading">読み込み中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<h1>管理者パネル</h1>
|
<h1>管理者パネル</h1>
|
||||||
<p className="subtitle">RSS Podcast Manager - 管理者用インターフェース</p>
|
<p className="subtitle">
|
||||||
|
RSS Podcast Manager - 管理者用インターフェース
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
@ -311,34 +334,34 @@ function App() {
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<nav style={{ display: 'flex', gap: '16px' }}>
|
<nav style={{ display: "flex", gap: "16px" }}>
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === 'dashboard' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${activeTab === "dashboard" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setActiveTab('dashboard')}
|
onClick={() => setActiveTab("dashboard")}
|
||||||
>
|
>
|
||||||
ダッシュボード
|
ダッシュボード
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === 'feeds' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${activeTab === "feeds" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setActiveTab('feeds')}
|
onClick={() => setActiveTab("feeds")}
|
||||||
>
|
>
|
||||||
フィード管理
|
フィード管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === 'batch' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${activeTab === "batch" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setActiveTab('batch')}
|
onClick={() => setActiveTab("batch")}
|
||||||
>
|
>
|
||||||
バッチ管理
|
バッチ管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === 'requests' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${activeTab === "requests" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setActiveTab('requests')}
|
onClick={() => setActiveTab("requests")}
|
||||||
>
|
>
|
||||||
フィード承認
|
フィード承認
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === 'env' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${activeTab === "env" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setActiveTab('env')}
|
onClick={() => setActiveTab("env")}
|
||||||
>
|
>
|
||||||
環境変数
|
環境変数
|
||||||
</button>
|
</button>
|
||||||
@ -346,7 +369,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-content">
|
<div className="card-content">
|
||||||
{activeTab === 'dashboard' && (
|
{activeTab === "dashboard" && (
|
||||||
<>
|
<>
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@ -370,44 +393,62 @@ function App() {
|
|||||||
<div className="label">保留中リクエスト</div>
|
<div className="label">保留中リクエスト</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value" style={{ color: stats?.batchScheduler?.enabled ? '#28a745' : '#dc3545' }}>
|
<div
|
||||||
{stats?.batchScheduler?.enabled ? 'ON' : 'OFF'}
|
className="value"
|
||||||
|
style={{
|
||||||
|
color: stats?.batchScheduler?.enabled
|
||||||
|
? "#28a745"
|
||||||
|
: "#dc3545",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats?.batchScheduler?.enabled ? "ON" : "OFF"}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">バッチスケジューラー</div>
|
<div className="label">バッチスケジューラー</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: "20px" }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
onClick={triggerBatch}
|
onClick={triggerBatch}
|
||||||
disabled={stats?.batchScheduler?.isRunning}
|
disabled={stats?.batchScheduler?.isRunning}
|
||||||
>
|
>
|
||||||
{stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'}
|
{stats?.batchScheduler?.isRunning
|
||||||
|
? "バッチ処理実行中..."
|
||||||
|
: "バッチ処理を手動実行"}
|
||||||
</button>
|
</button>
|
||||||
{stats?.batchScheduler?.canForceStop && (
|
{stats?.batchScheduler?.canForceStop && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
onClick={forceStopBatch}
|
onClick={forceStopBatch}
|
||||||
style={{ marginLeft: '8px' }}
|
style={{ marginLeft: "8px" }}
|
||||||
>
|
>
|
||||||
強制停止
|
強制停止
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-primary" onClick={loadData} style={{ marginLeft: '8px' }}>
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={loadData}
|
||||||
|
style={{ marginLeft: "8px" }}
|
||||||
|
>
|
||||||
データを再読み込み
|
データを再読み込み
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: '14px', color: '#7f8c8d' }}>
|
<div style={{ fontSize: "14px", color: "#7f8c8d" }}>
|
||||||
<p>管理者パネルポート: {stats?.adminPort}</p>
|
<p>管理者パネルポート: {stats?.adminPort}</p>
|
||||||
<p>認証: {stats?.authEnabled ? '有効' : '無効'}</p>
|
<p>認証: {stats?.authEnabled ? "有効" : "無効"}</p>
|
||||||
<p>最終更新: {stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleString('ja-JP') : '不明'}</p>
|
<p>
|
||||||
|
最終更新:{" "}
|
||||||
|
{stats?.lastUpdated
|
||||||
|
? new Date(stats.lastUpdated).toLocaleString("ja-JP")
|
||||||
|
: "不明"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'feeds' && (
|
{activeTab === "feeds" && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={addFeed} className="add-feed-form">
|
<form onSubmit={addFeed} className="add-feed-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@ -427,10 +468,16 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style={{ marginTop: '24px' }}>
|
<div style={{ marginTop: "24px" }}>
|
||||||
<h3>フィード一覧 ({feeds.length}件)</h3>
|
<h3>フィード一覧 ({feeds.length}件)</h3>
|
||||||
{feeds.length === 0 ? (
|
{feeds.length === 0 ? (
|
||||||
<p style={{ color: '#7f8c8d', textAlign: 'center', padding: '20px' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#7f8c8d",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
フィードが登録されていません
|
フィードが登録されていません
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@ -438,18 +485,20 @@ function App() {
|
|||||||
{feeds.map((feed) => (
|
{feeds.map((feed) => (
|
||||||
<li key={feed.id} className="feed-item">
|
<li key={feed.id} className="feed-item">
|
||||||
<div className="feed-info">
|
<div className="feed-info">
|
||||||
<h3>{feed.title || 'タイトル未設定'}</h3>
|
<h3>{feed.title || "タイトル未設定"}</h3>
|
||||||
<div className="url">{feed.url}</div>
|
<div className="url">{feed.url}</div>
|
||||||
<span className={`status ${feed.active ? 'active' : 'inactive'}`}>
|
<span
|
||||||
{feed.active ? 'アクティブ' : '非アクティブ'}
|
className={`status ${feed.active ? "active" : "inactive"}`}
|
||||||
|
>
|
||||||
|
{feed.active ? "アクティブ" : "非アクティブ"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="feed-actions">
|
<div className="feed-actions">
|
||||||
<button
|
<button
|
||||||
className={`btn ${feed.active ? 'btn-warning' : 'btn-success'}`}
|
className={`btn ${feed.active ? "btn-warning" : "btn-success"}`}
|
||||||
onClick={() => toggleFeed(feed.id, !feed.active)}
|
onClick={() => toggleFeed(feed.id, !feed.active)}
|
||||||
>
|
>
|
||||||
{feed.active ? '無効化' : '有効化'}
|
{feed.active ? "無効化" : "有効化"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
@ -466,55 +515,75 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'batch' && (
|
{activeTab === "batch" && (
|
||||||
<>
|
<>
|
||||||
<h3>バッチ処理管理</h3>
|
<h3>バッチ処理管理</h3>
|
||||||
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}>
|
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
|
||||||
定期バッチ処理の状態管理と手動実行を行えます。
|
定期バッチ処理の状態管理と手動実行を行えます。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: '24px' }}>
|
<div className="stats-grid" style={{ marginBottom: "24px" }}>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value" style={{ color: stats?.batchScheduler?.enabled ? '#28a745' : '#dc3545' }}>
|
<div
|
||||||
{stats?.batchScheduler?.enabled ? '有効' : '無効'}
|
className="value"
|
||||||
|
style={{
|
||||||
|
color: stats?.batchScheduler?.enabled
|
||||||
|
? "#28a745"
|
||||||
|
: "#dc3545",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats?.batchScheduler?.enabled ? "有効" : "無効"}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">スケジューラー状態</div>
|
<div className="label">スケジューラー状態</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value" style={{ color: stats?.batchScheduler?.isRunning ? '#ffc107' : '#6c757d' }}>
|
<div
|
||||||
{stats?.batchScheduler?.isRunning ? '実行中' : '待機中'}
|
className="value"
|
||||||
|
style={{
|
||||||
|
color: stats?.batchScheduler?.isRunning
|
||||||
|
? "#ffc107"
|
||||||
|
: "#6c757d",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats?.batchScheduler?.isRunning ? "実行中" : "待機中"}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">実行状態</div>
|
<div className="label">実行状態</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value" style={{ fontSize: '12px' }}>
|
<div className="value" style={{ fontSize: "12px" }}>
|
||||||
{stats?.batchScheduler?.lastRun
|
{stats?.batchScheduler?.lastRun
|
||||||
? new Date(stats.batchScheduler.lastRun).toLocaleString('ja-JP')
|
? new Date(stats.batchScheduler.lastRun).toLocaleString(
|
||||||
: '未実行'}
|
"ja-JP",
|
||||||
|
)
|
||||||
|
: "未実行"}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">前回実行</div>
|
<div className="label">前回実行</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value" style={{ fontSize: '12px' }}>
|
<div className="value" style={{ fontSize: "12px" }}>
|
||||||
{stats?.batchScheduler?.nextRun
|
{stats?.batchScheduler?.nextRun
|
||||||
? new Date(stats.batchScheduler.nextRun).toLocaleString('ja-JP')
|
? new Date(stats.batchScheduler.nextRun).toLocaleString(
|
||||||
: '未予定'}
|
"ja-JP",
|
||||||
|
)
|
||||||
|
: "未予定"}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">次回実行</div>
|
<div className="label">次回実行</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
<div style={{ marginBottom: "24px" }}>
|
||||||
<h4>スケジューラー制御</h4>
|
<h4>スケジューラー制御</h4>
|
||||||
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}>
|
<div
|
||||||
<button
|
style={{ display: "flex", gap: "12px", marginTop: "12px" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
onClick={() => toggleBatchScheduler(true)}
|
onClick={() => toggleBatchScheduler(true)}
|
||||||
disabled={stats?.batchScheduler?.enabled}
|
disabled={stats?.batchScheduler?.enabled}
|
||||||
>
|
>
|
||||||
スケジューラーを有効化
|
スケジューラーを有効化
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-warning"
|
className="btn btn-warning"
|
||||||
onClick={() => toggleBatchScheduler(false)}
|
onClick={() => toggleBatchScheduler(false)}
|
||||||
disabled={!stats?.batchScheduler?.enabled}
|
disabled={!stats?.batchScheduler?.enabled}
|
||||||
@ -522,147 +591,248 @@ function App() {
|
|||||||
スケジューラーを無効化
|
スケジューラーを無効化
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#6c757d",
|
||||||
|
marginTop: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
スケジューラーを無効化すると、定期的なバッチ処理が停止します。手動実行は引き続き可能です。
|
スケジューラーを無効化すると、定期的なバッチ処理が停止します。手動実行は引き続き可能です。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
<div style={{ marginBottom: "24px" }}>
|
||||||
<h4>手動実行</h4>
|
<h4>手動実行</h4>
|
||||||
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}>
|
<div
|
||||||
<button
|
style={{ display: "flex", gap: "12px", marginTop: "12px" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={triggerBatch}
|
onClick={triggerBatch}
|
||||||
disabled={stats?.batchScheduler?.isRunning}
|
disabled={stats?.batchScheduler?.isRunning}
|
||||||
>
|
>
|
||||||
{stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'}
|
{stats?.batchScheduler?.isRunning
|
||||||
|
? "バッチ処理実行中..."
|
||||||
|
: "バッチ処理を手動実行"}
|
||||||
</button>
|
</button>
|
||||||
{stats?.batchScheduler?.canForceStop && (
|
{stats?.batchScheduler?.canForceStop && (
|
||||||
<button
|
<button className="btn btn-danger" onClick={forceStopBatch}>
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={forceStopBatch}
|
|
||||||
>
|
|
||||||
強制停止
|
強制停止
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#6c757d",
|
||||||
|
marginTop: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
スケジューラーの状態に関係なく、バッチ処理を手動で実行できます。実行中の場合は強制停止も可能です。
|
スケジューラーの状態に関係なく、バッチ処理を手動で実行できます。実行中の場合は強制停止も可能です。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '16px', background: '#f8f9fa', borderRadius: '4px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
background: "#f8f9fa",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h4>バッチ処理について</h4>
|
<h4>バッチ処理について</h4>
|
||||||
<ul style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px', paddingLeft: '20px' }}>
|
<ul
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#6c757d",
|
||||||
|
marginTop: "8px",
|
||||||
|
paddingLeft: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<li>定期バッチ処理は6時間ごとに実行されます</li>
|
<li>定期バッチ処理は6時間ごとに実行されます</li>
|
||||||
<li>新しいRSS記事の取得、要約生成、音声合成を行います</li>
|
<li>新しいRSS記事の取得、要約生成、音声合成を行います</li>
|
||||||
<li>スケジューラーが無効の場合、定期実行は停止しますが手動実行は可能です</li>
|
<li>
|
||||||
<li>バッチ処理の実行中は重複実行を防ぐため、新たな実行はスキップされます</li>
|
スケジューラーが無効の場合、定期実行は停止しますが手動実行は可能です
|
||||||
<li><strong>強制停止:</strong> 実行中のバッチ処理を緊急停止できます(データ整合性に注意)</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
バッチ処理の実行中は重複実行を防ぐため、新たな実行はスキップされます
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>強制停止:</strong>{" "}
|
||||||
|
実行中のバッチ処理を緊急停止できます(データ整合性に注意)
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'requests' && (
|
{activeTab === "requests" && (
|
||||||
<>
|
<>
|
||||||
<h3>フィードリクエスト管理</h3>
|
<h3>フィードリクエスト管理</h3>
|
||||||
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}>
|
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
|
||||||
ユーザーから送信されたフィード追加リクエストを承認・拒否できます。
|
ユーザーから送信されたフィード追加リクエストを承認・拒否できます。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: "20px" }}>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
<button
|
<button
|
||||||
className={`btn ${requestFilter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${requestFilter === "all" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setRequestFilter('all')}
|
onClick={() => setRequestFilter("all")}
|
||||||
>
|
>
|
||||||
すべて ({feedRequests.length})
|
すべて ({feedRequests.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${requestFilter === 'pending' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${requestFilter === "pending" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setRequestFilter('pending')}
|
onClick={() => setRequestFilter("pending")}
|
||||||
>
|
>
|
||||||
保留中 ({feedRequests.filter(r => r.status === 'pending').length})
|
保留中 (
|
||||||
|
{feedRequests.filter((r) => r.status === "pending").length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${requestFilter === 'approved' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${requestFilter === "approved" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setRequestFilter('approved')}
|
onClick={() => setRequestFilter("approved")}
|
||||||
>
|
>
|
||||||
承認済み ({feedRequests.filter(r => r.status === 'approved').length})
|
承認済み (
|
||||||
|
{feedRequests.filter((r) => r.status === "approved").length}
|
||||||
|
)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${requestFilter === 'rejected' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${requestFilter === "rejected" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setRequestFilter('rejected')}
|
onClick={() => setRequestFilter("rejected")}
|
||||||
>
|
>
|
||||||
拒否済み ({feedRequests.filter(r => r.status === 'rejected').length})
|
拒否済み (
|
||||||
|
{feedRequests.filter((r) => r.status === "rejected").length}
|
||||||
|
)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredRequests.length === 0 ? (
|
{filteredRequests.length === 0 ? (
|
||||||
<p style={{ color: '#7f8c8d', textAlign: 'center', padding: '20px' }}>
|
<p
|
||||||
{requestFilter === 'all' ? 'フィードリクエストがありません' : `${requestFilter === 'pending' ? '保留中' : requestFilter === 'approved' ? '承認済み' : '拒否済み'}のリクエストがありません`}
|
style={{
|
||||||
|
color: "#7f8c8d",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{requestFilter === "all"
|
||||||
|
? "フィードリクエストがありません"
|
||||||
|
: `${requestFilter === "pending" ? "保留中" : requestFilter === "approved" ? "承認済み" : "拒否済み"}のリクエストがありません`}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="requests-list">
|
<div className="requests-list">
|
||||||
{filteredRequests.map((request) => (
|
{filteredRequests.map((request) => (
|
||||||
<div key={request.id} className="feed-item" style={{ marginBottom: '16px' }}>
|
<div
|
||||||
|
key={request.id}
|
||||||
|
className="feed-item"
|
||||||
|
style={{ marginBottom: "16px" }}
|
||||||
|
>
|
||||||
<div className="feed-info">
|
<div className="feed-info">
|
||||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>{request.url}</h4>
|
<h4 style={{ margin: "0 0 8px 0", fontSize: "16px" }}>
|
||||||
|
{request.url}
|
||||||
|
</h4>
|
||||||
{request.requestMessage && (
|
{request.requestMessage && (
|
||||||
<p style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#666' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
メッセージ: {request.requestMessage}
|
メッセージ: {request.requestMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
<div style={{ fontSize: "12px", color: "#999" }}>
|
||||||
<span>申請者: {request.requestedBy || '匿名'}</span>
|
<span>申請者: {request.requestedBy || "匿名"}</span>
|
||||||
<span style={{ margin: '0 8px' }}>|</span>
|
<span style={{ margin: "0 8px" }}>|</span>
|
||||||
<span>申請日: {new Date(request.createdAt).toLocaleString('ja-JP')}</span>
|
<span>
|
||||||
|
申請日:{" "}
|
||||||
|
{new Date(request.createdAt).toLocaleString(
|
||||||
|
"ja-JP",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{request.reviewedAt && (
|
{request.reviewedAt && (
|
||||||
<>
|
<>
|
||||||
<span style={{ margin: '0 8px' }}>|</span>
|
<span style={{ margin: "0 8px" }}>|</span>
|
||||||
<span>審査日: {new Date(request.reviewedAt).toLocaleString('ja-JP')}</span>
|
<span>
|
||||||
|
審査日:{" "}
|
||||||
|
{new Date(request.reviewedAt).toLocaleString(
|
||||||
|
"ja-JP",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`status ${request.status === 'approved' ? 'active' : request.status === 'rejected' ? 'inactive' : ''}`}>
|
<span
|
||||||
{request.status === 'pending' ? '保留中' : request.status === 'approved' ? '承認済み' : '拒否済み'}
|
className={`status ${request.status === "approved" ? "active" : request.status === "rejected" ? "inactive" : ""}`}
|
||||||
|
>
|
||||||
|
{request.status === "pending"
|
||||||
|
? "保留中"
|
||||||
|
: request.status === "approved"
|
||||||
|
? "承認済み"
|
||||||
|
: "拒否済み"}
|
||||||
</span>
|
</span>
|
||||||
{request.adminNotes && (
|
{request.adminNotes && (
|
||||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f8f9fa', borderRadius: '4px', fontSize: '14px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#f8f9fa",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<strong>管理者メモ:</strong> {request.adminNotes}
|
<strong>管理者メモ:</strong> {request.adminNotes}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{request.status === 'pending' && (
|
{request.status === "pending" && (
|
||||||
<div className="feed-actions" style={{ flexDirection: 'column', gap: '8px', minWidth: '200px' }}>
|
<div
|
||||||
|
className="feed-actions"
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
minWidth: "200px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="管理者メモ(任意)"
|
placeholder="管理者メモ(任意)"
|
||||||
value={approvalNotes[request.id] || ''}
|
value={approvalNotes[request.id] || ""}
|
||||||
onChange={(e) => updateApprovalNotes(request.id, e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateApprovalNotes(request.id, e.target.value)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
minHeight: '60px',
|
minHeight: "60px",
|
||||||
padding: '8px',
|
padding: "8px",
|
||||||
border: '1px solid #ddd',
|
border: "1px solid #ddd",
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
fontSize: '12px',
|
fontSize: "12px",
|
||||||
resize: 'vertical'
|
resize: "vertical",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
<div style={{ display: "flex", gap: "4px" }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
onClick={() => approveFeedRequest(request.id, approvalNotes[request.id] || '')}
|
onClick={() =>
|
||||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
approveFeedRequest(
|
||||||
|
request.id,
|
||||||
|
approvalNotes[request.id] || "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ fontSize: "12px", padding: "6px 12px" }}
|
||||||
>
|
>
|
||||||
承認
|
承認
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
onClick={() => rejectFeedRequest(request.id, approvalNotes[request.id] || '')}
|
onClick={() =>
|
||||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
rejectFeedRequest(
|
||||||
|
request.id,
|
||||||
|
approvalNotes[request.id] || "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ fontSize: "12px", padding: "6px 12px" }}
|
||||||
>
|
>
|
||||||
拒否
|
拒否
|
||||||
</button>
|
</button>
|
||||||
@ -676,27 +846,40 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'env' && (
|
{activeTab === "env" && (
|
||||||
<>
|
<>
|
||||||
<h3>環境変数設定</h3>
|
<h3>環境変数設定</h3>
|
||||||
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}>
|
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
|
||||||
現在の環境変数設定を表示しています。機密情報は***SET***と表示されます。
|
現在の環境変数設定を表示しています。機密情報は***SET***と表示されます。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="env-list">
|
<ul className="env-list">
|
||||||
{Object.entries(envVars).map(([key, value]) => (
|
{Object.entries(envVars).map(([key, value]) => (
|
||||||
<li key={key} className="env-item">
|
<li key={key} className="env-item">
|
||||||
<div className="env-key">{key}</div>
|
<div className="env-key">{key}</div>
|
||||||
<div className="env-value">
|
<div className="env-value">
|
||||||
{value === undefined ? '未設定' : value}
|
{value === undefined ? "未設定" : value}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px', padding: '16px', background: '#f8f9fa', borderRadius: '4px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "20px",
|
||||||
|
padding: "16px",
|
||||||
|
background: "#f8f9fa",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h4>環境変数の設定方法</h4>
|
<h4>環境変数の設定方法</h4>
|
||||||
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#6c757d",
|
||||||
|
marginTop: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
環境変数を変更するには、.envファイルを編集するか、システムの環境変数を設定してください。
|
環境変数を変更するには、.envファイルを編集するか、システムの環境変数を設定してください。
|
||||||
変更後はサーバーの再起動が必要です。
|
変更後はサーバーの再起動が必要です。
|
||||||
</p>
|
</p>
|
||||||
@ -709,4 +892,4 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@ -24,7 +24,7 @@ body {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ body {
|
|||||||
.card {
|
.card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -147,7 +147,7 @@ body {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,4 +275,4 @@ body {
|
|||||||
.add-feed-form .form-group {
|
.add-feed-form .form-group {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React from 'react'
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from "react-dom/client";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
);
|
||||||
|
@ -18,4 +18,4 @@
|
|||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
@ -7,4 +7,4 @@
|
|||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: "dist",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
302
admin-server.ts
302
admin-server.ts
@ -3,7 +3,7 @@ import { serve } from "@hono/node-server";
|
|||||||
import { basicAuth } from "hono/basic-auth";
|
import { basicAuth } from "hono/basic-auth";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { config, validateConfig } from "./services/config.js";
|
import { config, validateConfig } from "./services/config.js";
|
||||||
import {
|
import {
|
||||||
getAllFeedsIncludingInactive,
|
getAllFeedsIncludingInactive,
|
||||||
deleteFeed,
|
deleteFeed,
|
||||||
toggleFeedActive,
|
toggleFeedActive,
|
||||||
@ -31,10 +31,13 @@ const app = new Hono();
|
|||||||
|
|
||||||
// Basic Authentication middleware (if credentials are provided)
|
// Basic Authentication middleware (if credentials are provided)
|
||||||
if (config.admin.username && config.admin.password) {
|
if (config.admin.username && config.admin.password) {
|
||||||
app.use("*", basicAuth({
|
app.use(
|
||||||
username: config.admin.username,
|
"*",
|
||||||
password: config.admin.password,
|
basicAuth({
|
||||||
}));
|
username: config.admin.username,
|
||||||
|
password: config.admin.password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
console.log("🔐 Admin panel authentication enabled");
|
console.log("🔐 Admin panel authentication enabled");
|
||||||
} else {
|
} else {
|
||||||
console.log("⚠️ Admin panel running without authentication");
|
console.log("⚠️ Admin panel running without authentication");
|
||||||
@ -45,14 +48,16 @@ app.get("/api/admin/env", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const envVars = {
|
const envVars = {
|
||||||
// OpenAI Configuration
|
// OpenAI Configuration
|
||||||
OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"] ? "***SET***" : undefined,
|
OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"]
|
||||||
|
? "***SET***"
|
||||||
|
: undefined,
|
||||||
OPENAI_API_ENDPOINT: import.meta.env["OPENAI_API_ENDPOINT"],
|
OPENAI_API_ENDPOINT: import.meta.env["OPENAI_API_ENDPOINT"],
|
||||||
OPENAI_MODEL_NAME: import.meta.env["OPENAI_MODEL_NAME"],
|
OPENAI_MODEL_NAME: import.meta.env["OPENAI_MODEL_NAME"],
|
||||||
|
|
||||||
// VOICEVOX Configuration
|
// VOICEVOX Configuration
|
||||||
VOICEVOX_HOST: import.meta.env["VOICEVOX_HOST"],
|
VOICEVOX_HOST: import.meta.env["VOICEVOX_HOST"],
|
||||||
VOICEVOX_STYLE_ID: import.meta.env["VOICEVOX_STYLE_ID"],
|
VOICEVOX_STYLE_ID: import.meta.env["VOICEVOX_STYLE_ID"],
|
||||||
|
|
||||||
// Podcast Configuration
|
// Podcast Configuration
|
||||||
PODCAST_TITLE: import.meta.env["PODCAST_TITLE"],
|
PODCAST_TITLE: import.meta.env["PODCAST_TITLE"],
|
||||||
PODCAST_LINK: import.meta.env["PODCAST_LINK"],
|
PODCAST_LINK: import.meta.env["PODCAST_LINK"],
|
||||||
@ -62,16 +67,20 @@ app.get("/api/admin/env", async (c) => {
|
|||||||
PODCAST_CATEGORIES: import.meta.env["PODCAST_CATEGORIES"],
|
PODCAST_CATEGORIES: import.meta.env["PODCAST_CATEGORIES"],
|
||||||
PODCAST_TTL: import.meta.env["PODCAST_TTL"],
|
PODCAST_TTL: import.meta.env["PODCAST_TTL"],
|
||||||
PODCAST_BASE_URL: import.meta.env["PODCAST_BASE_URL"],
|
PODCAST_BASE_URL: import.meta.env["PODCAST_BASE_URL"],
|
||||||
|
|
||||||
// Admin Configuration
|
// Admin Configuration
|
||||||
ADMIN_PORT: import.meta.env["ADMIN_PORT"],
|
ADMIN_PORT: import.meta.env["ADMIN_PORT"],
|
||||||
ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"] ? "***SET***" : undefined,
|
ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"]
|
||||||
ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"] ? "***SET***" : undefined,
|
? "***SET***"
|
||||||
|
: undefined,
|
||||||
|
ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"]
|
||||||
|
? "***SET***"
|
||||||
|
: undefined,
|
||||||
|
|
||||||
// File Configuration
|
// File Configuration
|
||||||
FEED_URLS_FILE: import.meta.env["FEED_URLS_FILE"],
|
FEED_URLS_FILE: import.meta.env["FEED_URLS_FILE"],
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json(envVars);
|
return c.json(envVars);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching environment variables:", error);
|
console.error("Error fetching environment variables:", error);
|
||||||
@ -93,30 +102,34 @@ app.get("/api/admin/feeds", async (c) => {
|
|||||||
app.post("/api/admin/feeds", async (c) => {
|
app.post("/api/admin/feeds", async (c) => {
|
||||||
try {
|
try {
|
||||||
const { feedUrl } = await c.req.json<{ feedUrl: string }>();
|
const { feedUrl } = await c.req.json<{ feedUrl: string }>();
|
||||||
|
|
||||||
if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) {
|
if (
|
||||||
|
!feedUrl ||
|
||||||
|
typeof feedUrl !== "string" ||
|
||||||
|
!feedUrl.startsWith("http")
|
||||||
|
) {
|
||||||
return c.json({ error: "Valid feed URL is required" }, 400);
|
return c.json({ error: "Valid feed URL is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("➕ Admin adding new feed URL:", feedUrl);
|
console.log("➕ Admin adding new feed URL:", feedUrl);
|
||||||
|
|
||||||
// Check if feed already exists
|
// Check if feed already exists
|
||||||
const existingFeed = await getFeedByUrl(feedUrl);
|
const existingFeed = await getFeedByUrl(feedUrl);
|
||||||
if (existingFeed) {
|
if (existingFeed) {
|
||||||
return c.json({
|
return c.json({
|
||||||
result: "EXISTS",
|
result: "EXISTS",
|
||||||
message: "Feed URL already exists",
|
message: "Feed URL already exists",
|
||||||
feed: existingFeed
|
feed: existingFeed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new feed
|
// Add new feed
|
||||||
await addNewFeedUrl(feedUrl);
|
await addNewFeedUrl(feedUrl);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
result: "CREATED",
|
result: "CREATED",
|
||||||
message: "Feed URL added successfully",
|
message: "Feed URL added successfully",
|
||||||
feedUrl
|
feedUrl,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error adding feed:", error);
|
console.error("Error adding feed:", error);
|
||||||
@ -127,20 +140,20 @@ app.post("/api/admin/feeds", async (c) => {
|
|||||||
app.delete("/api/admin/feeds/:id", async (c) => {
|
app.delete("/api/admin/feeds/:id", async (c) => {
|
||||||
try {
|
try {
|
||||||
const feedId = c.req.param("id");
|
const feedId = c.req.param("id");
|
||||||
|
|
||||||
if (!feedId || feedId.trim() === "") {
|
if (!feedId || feedId.trim() === "") {
|
||||||
return c.json({ error: "Feed ID is required" }, 400);
|
return c.json({ error: "Feed ID is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🗑️ Admin deleting feed ID:", feedId);
|
console.log("🗑️ Admin deleting feed ID:", feedId);
|
||||||
|
|
||||||
const deleted = await deleteFeed(feedId);
|
const deleted = await deleteFeed(feedId);
|
||||||
|
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
return c.json({
|
return c.json({
|
||||||
result: "DELETED",
|
result: "DELETED",
|
||||||
message: "Feed deleted successfully",
|
message: "Feed deleted successfully",
|
||||||
feedId
|
feedId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return c.json({ error: "Feed not found" }, 404);
|
return c.json({ error: "Feed not found" }, 404);
|
||||||
@ -155,25 +168,27 @@ app.patch("/api/admin/feeds/:id/toggle", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const feedId = c.req.param("id");
|
const feedId = c.req.param("id");
|
||||||
const { active } = await c.req.json<{ active: boolean }>();
|
const { active } = await c.req.json<{ active: boolean }>();
|
||||||
|
|
||||||
if (!feedId || feedId.trim() === "") {
|
if (!feedId || feedId.trim() === "") {
|
||||||
return c.json({ error: "Feed ID is required" }, 400);
|
return c.json({ error: "Feed ID is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof active !== "boolean") {
|
if (typeof active !== "boolean") {
|
||||||
return c.json({ error: "Active status must be a boolean" }, 400);
|
return c.json({ error: "Active status must be a boolean" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`);
|
console.log(
|
||||||
|
`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`,
|
||||||
|
);
|
||||||
|
|
||||||
const updated = await toggleFeedActive(feedId, active);
|
const updated = await toggleFeedActive(feedId, active);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
return c.json({
|
return c.json({
|
||||||
result: "UPDATED",
|
result: "UPDATED",
|
||||||
message: `Feed ${active ? "activated" : "deactivated"} successfully`,
|
message: `Feed ${active ? "activated" : "deactivated"} successfully`,
|
||||||
feedId,
|
feedId,
|
||||||
active
|
active,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return c.json({ error: "Feed not found" }, 404);
|
return c.json({ error: "Feed not found" }, 404);
|
||||||
@ -209,62 +224,85 @@ app.get("/api/admin/episodes/simple", async (c) => {
|
|||||||
app.get("/api/admin/db-diagnostic", async (c) => {
|
app.get("/api/admin/db-diagnostic", async (c) => {
|
||||||
try {
|
try {
|
||||||
const db = new Database(config.paths.dbPath);
|
const db = new Database(config.paths.dbPath);
|
||||||
|
|
||||||
// 1. Check episodes table
|
// 1. Check episodes table
|
||||||
const episodeCount = db.prepare("SELECT COUNT(*) as count FROM episodes").get() as any;
|
const episodeCount = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM episodes")
|
||||||
|
.get() as any;
|
||||||
|
|
||||||
// 2. Check articles table
|
// 2. Check articles table
|
||||||
const articleCount = db.prepare("SELECT COUNT(*) as count FROM articles").get() as any;
|
const articleCount = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM articles")
|
||||||
|
.get() as any;
|
||||||
|
|
||||||
// 3. Check feeds table
|
// 3. Check feeds table
|
||||||
const feedCount = db.prepare("SELECT COUNT(*) as count FROM feeds").get() as any;
|
const feedCount = db
|
||||||
const activeFeedCount = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1").get() as any;
|
.prepare("SELECT COUNT(*) as count FROM feeds")
|
||||||
const inactiveFeedCount = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 0 OR active IS NULL").get() as any;
|
.get() as any;
|
||||||
|
const activeFeedCount = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1")
|
||||||
|
.get() as any;
|
||||||
|
const inactiveFeedCount = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM feeds WHERE active = 0 OR active IS NULL",
|
||||||
|
)
|
||||||
|
.get() as any;
|
||||||
|
|
||||||
// 4. Check orphaned episodes
|
// 4. Check orphaned episodes
|
||||||
const orphanedEpisodes = db.prepare(`
|
const orphanedEpisodes = db
|
||||||
|
.prepare(`
|
||||||
SELECT e.id, e.title, e.article_id
|
SELECT e.id, e.title, e.article_id
|
||||||
FROM episodes e
|
FROM episodes e
|
||||||
LEFT JOIN articles a ON e.article_id = a.id
|
LEFT JOIN articles a ON e.article_id = a.id
|
||||||
WHERE a.id IS NULL
|
WHERE a.id IS NULL
|
||||||
`).all() as any[];
|
`)
|
||||||
|
.all() as any[];
|
||||||
|
|
||||||
// 5. Check orphaned articles
|
// 5. Check orphaned articles
|
||||||
const orphanedArticles = db.prepare(`
|
const orphanedArticles = db
|
||||||
|
.prepare(`
|
||||||
SELECT a.id, a.title, a.feed_id
|
SELECT a.id, a.title, a.feed_id
|
||||||
FROM articles a
|
FROM articles a
|
||||||
LEFT JOIN feeds f ON a.feed_id = f.id
|
LEFT JOIN feeds f ON a.feed_id = f.id
|
||||||
WHERE f.id IS NULL
|
WHERE f.id IS NULL
|
||||||
`).all() as any[];
|
`)
|
||||||
|
.all() as any[];
|
||||||
|
|
||||||
// 6. Check episodes with articles but feeds are inactive
|
// 6. Check episodes with articles but feeds are inactive
|
||||||
const episodesInactiveFeeds = db.prepare(`
|
const episodesInactiveFeeds = db
|
||||||
|
.prepare(`
|
||||||
SELECT e.id, e.title, f.active, f.title as feed_title
|
SELECT e.id, e.title, f.active, f.title as feed_title
|
||||||
FROM episodes e
|
FROM episodes e
|
||||||
JOIN articles a ON e.article_id = a.id
|
JOIN articles a ON e.article_id = a.id
|
||||||
JOIN feeds f ON a.feed_id = f.id
|
JOIN feeds f ON a.feed_id = f.id
|
||||||
WHERE f.active = 0 OR f.active IS NULL
|
WHERE f.active = 0 OR f.active IS NULL
|
||||||
`).all() as any[];
|
`)
|
||||||
|
.all() as any[];
|
||||||
|
|
||||||
// 7. Test the JOIN query
|
// 7. Test the JOIN query
|
||||||
const joinResult = db.prepare(`
|
const joinResult = db
|
||||||
|
.prepare(`
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM episodes e
|
FROM episodes e
|
||||||
JOIN articles a ON e.article_id = a.id
|
JOIN articles a ON e.article_id = a.id
|
||||||
JOIN feeds f ON a.feed_id = f.id
|
JOIN feeds f ON a.feed_id = f.id
|
||||||
WHERE f.active = 1
|
WHERE f.active = 1
|
||||||
`).get() as any;
|
`)
|
||||||
|
.get() as any;
|
||||||
|
|
||||||
// 8. Sample feed details
|
// 8. Sample feed details
|
||||||
const sampleFeeds = db.prepare(`
|
const sampleFeeds = db
|
||||||
|
.prepare(`
|
||||||
SELECT id, title, url, active, created_at
|
SELECT id, title, url, active, created_at
|
||||||
FROM feeds
|
FROM feeds
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
`).all() as any[];
|
`)
|
||||||
|
.all() as any[];
|
||||||
|
|
||||||
// 9. Sample episode-article-feed chain
|
// 9. Sample episode-article-feed chain
|
||||||
const sampleChain = db.prepare(`
|
const sampleChain = db
|
||||||
|
.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
e.id as episode_id, e.title as episode_title,
|
e.id as episode_id, e.title as episode_title,
|
||||||
a.id as article_id, a.title as article_title,
|
a.id as article_id, a.title as article_title,
|
||||||
@ -274,10 +312,11 @@ app.get("/api/admin/db-diagnostic", async (c) => {
|
|||||||
LEFT JOIN feeds f ON a.feed_id = f.id
|
LEFT JOIN feeds f ON a.feed_id = f.id
|
||||||
ORDER BY e.created_at DESC
|
ORDER BY e.created_at DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
`).all() as any[];
|
`)
|
||||||
|
.all() as any[];
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
const diagnosticResult = {
|
const diagnosticResult = {
|
||||||
counts: {
|
counts: {
|
||||||
episodes: episodeCount.count,
|
episodes: episodeCount.count,
|
||||||
@ -305,11 +344,14 @@ app.get("/api/admin/db-diagnostic", async (c) => {
|
|||||||
},
|
},
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json(diagnosticResult);
|
return c.json(diagnosticResult);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error running database diagnostic:", error);
|
console.error("Error running database diagnostic:", error);
|
||||||
return c.json({ error: "Failed to run database diagnostic", details: error.message }, 500);
|
return c.json(
|
||||||
|
{ error: "Failed to run database diagnostic", details: error.message },
|
||||||
|
500,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -330,37 +372,37 @@ app.patch("/api/admin/feed-requests/:id/approve", async (c) => {
|
|||||||
const requestId = c.req.param("id");
|
const requestId = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { adminNotes } = body;
|
const { adminNotes } = body;
|
||||||
|
|
||||||
// First get the request to get the URL
|
// First get the request to get the URL
|
||||||
const requests = await getFeedRequests();
|
const requests = await getFeedRequests();
|
||||||
const request = requests.find(r => r.id === requestId);
|
const request = requests.find((r) => r.id === requestId);
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return c.json({ error: "Feed request not found" }, 404);
|
return c.json({ error: "Feed request not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.status !== 'pending') {
|
if (request.status !== "pending") {
|
||||||
return c.json({ error: "Feed request already processed" }, 400);
|
return c.json({ error: "Feed request already processed" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the feed
|
// Add the feed
|
||||||
await addNewFeedUrl(request.url);
|
await addNewFeedUrl(request.url);
|
||||||
|
|
||||||
// Update request status
|
// Update request status
|
||||||
const updated = await updateFeedRequestStatus(
|
const updated = await updateFeedRequestStatus(
|
||||||
requestId,
|
requestId,
|
||||||
'approved',
|
"approved",
|
||||||
'admin',
|
"admin",
|
||||||
adminNotes
|
adminNotes,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return c.json({ error: "Failed to update request status" }, 500);
|
return c.json({ error: "Failed to update request status" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Feed request approved and feed added successfully"
|
message: "Feed request approved and feed added successfully",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error approving feed request:", error);
|
console.error("Error approving feed request:", error);
|
||||||
@ -373,21 +415,21 @@ app.patch("/api/admin/feed-requests/:id/reject", async (c) => {
|
|||||||
const requestId = c.req.param("id");
|
const requestId = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { adminNotes } = body;
|
const { adminNotes } = body;
|
||||||
|
|
||||||
const updated = await updateFeedRequestStatus(
|
const updated = await updateFeedRequestStatus(
|
||||||
requestId,
|
requestId,
|
||||||
'rejected',
|
"rejected",
|
||||||
'admin',
|
"admin",
|
||||||
adminNotes
|
adminNotes,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return c.json({ error: "Feed request not found" }, 404);
|
return c.json({ error: "Feed request not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Feed request rejected successfully"
|
message: "Feed request rejected successfully",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error rejecting feed request:", error);
|
console.error("Error rejecting feed request:", error);
|
||||||
@ -409,10 +451,10 @@ app.get("/api/admin/batch/status", async (c) => {
|
|||||||
app.post("/api/admin/batch/enable", async (c) => {
|
app.post("/api/admin/batch/enable", async (c) => {
|
||||||
try {
|
try {
|
||||||
batchScheduler.enable();
|
batchScheduler.enable();
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Batch scheduler enabled successfully",
|
message: "Batch scheduler enabled successfully",
|
||||||
status: batchScheduler.getStatus()
|
status: batchScheduler.getStatus(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error enabling batch scheduler:", error);
|
console.error("Error enabling batch scheduler:", error);
|
||||||
@ -423,10 +465,10 @@ app.post("/api/admin/batch/enable", async (c) => {
|
|||||||
app.post("/api/admin/batch/disable", async (c) => {
|
app.post("/api/admin/batch/disable", async (c) => {
|
||||||
try {
|
try {
|
||||||
batchScheduler.disable();
|
batchScheduler.disable();
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Batch scheduler disabled successfully",
|
message: "Batch scheduler disabled successfully",
|
||||||
status: batchScheduler.getStatus()
|
status: batchScheduler.getStatus(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error disabling batch scheduler:", error);
|
console.error("Error disabling batch scheduler:", error);
|
||||||
@ -441,13 +483,14 @@ app.get("/api/admin/stats", async (c) => {
|
|||||||
const episodes = await fetchAllEpisodes();
|
const episodes = await fetchAllEpisodes();
|
||||||
const feedRequests = await getFeedRequests();
|
const feedRequests = await getFeedRequests();
|
||||||
const batchStatus = batchScheduler.getStatus();
|
const batchStatus = batchScheduler.getStatus();
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalFeeds: feeds.length,
|
totalFeeds: feeds.length,
|
||||||
activeFeeds: feeds.filter(f => f.active).length,
|
activeFeeds: feeds.filter((f) => f.active).length,
|
||||||
inactiveFeeds: feeds.filter(f => !f.active).length,
|
inactiveFeeds: feeds.filter((f) => !f.active).length,
|
||||||
totalEpisodes: episodes.length,
|
totalEpisodes: episodes.length,
|
||||||
pendingRequests: feedRequests.filter(r => r.status === 'pending').length,
|
pendingRequests: feedRequests.filter((r) => r.status === "pending")
|
||||||
|
.length,
|
||||||
totalRequests: feedRequests.length,
|
totalRequests: feedRequests.length,
|
||||||
batchScheduler: {
|
batchScheduler: {
|
||||||
enabled: batchStatus.enabled,
|
enabled: batchStatus.enabled,
|
||||||
@ -459,7 +502,7 @@ app.get("/api/admin/stats", async (c) => {
|
|||||||
adminPort: config.admin.port,
|
adminPort: config.admin.port,
|
||||||
authEnabled: !!(config.admin.username && config.admin.password),
|
authEnabled: !!(config.admin.username && config.admin.password),
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json(stats);
|
return c.json(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching admin stats:", error);
|
console.error("Error fetching admin stats:", error);
|
||||||
@ -470,16 +513,16 @@ app.get("/api/admin/stats", async (c) => {
|
|||||||
app.post("/api/admin/batch/trigger", async (c) => {
|
app.post("/api/admin/batch/trigger", async (c) => {
|
||||||
try {
|
try {
|
||||||
console.log("🚀 Manual batch process triggered via admin panel");
|
console.log("🚀 Manual batch process triggered via admin panel");
|
||||||
|
|
||||||
// Use the batch scheduler's manual trigger method
|
// Use the batch scheduler's manual trigger method
|
||||||
batchScheduler.triggerManualRun().catch(error => {
|
batchScheduler.triggerManualRun().catch((error) => {
|
||||||
console.error("❌ Manual admin batch process failed:", error);
|
console.error("❌ Manual admin batch process failed:", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
result: "TRIGGERED",
|
result: "TRIGGERED",
|
||||||
message: "Batch process started in background",
|
message: "Batch process started in background",
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error triggering admin batch process:", error);
|
console.error("Error triggering admin batch process:", error);
|
||||||
@ -490,21 +533,24 @@ app.post("/api/admin/batch/trigger", async (c) => {
|
|||||||
app.post("/api/admin/batch/force-stop", async (c) => {
|
app.post("/api/admin/batch/force-stop", async (c) => {
|
||||||
try {
|
try {
|
||||||
console.log("🛑 Force stop batch process requested via admin panel");
|
console.log("🛑 Force stop batch process requested via admin panel");
|
||||||
|
|
||||||
const stopped = batchScheduler.forceStop();
|
const stopped = batchScheduler.forceStop();
|
||||||
|
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
return c.json({
|
return c.json({
|
||||||
result: "STOPPED",
|
result: "STOPPED",
|
||||||
message: "Batch process force stop signal sent",
|
message: "Batch process force stop signal sent",
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return c.json({
|
return c.json(
|
||||||
result: "NO_PROCESS",
|
{
|
||||||
message: "No batch process is currently running",
|
result: "NO_PROCESS",
|
||||||
timestamp: new Date().toISOString()
|
message: "No batch process is currently running",
|
||||||
}, 200);
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error force stopping batch process:", error);
|
console.error("Error force stopping batch process:", error);
|
||||||
@ -517,7 +563,7 @@ app.get("/assets/*", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const filePath = path.join(config.paths.adminBuildDir, c.req.path);
|
const filePath = path.join(config.paths.adminBuildDir, c.req.path);
|
||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const contentType = filePath.endsWith(".js")
|
const contentType = filePath.endsWith(".js")
|
||||||
? "application/javascript"
|
? "application/javascript"
|
||||||
@ -539,12 +585,12 @@ async function serveAdminIndex(c: any) {
|
|||||||
try {
|
try {
|
||||||
const indexPath = path.join(config.paths.adminBuildDir, "index.html");
|
const indexPath = path.join(config.paths.adminBuildDir, "index.html");
|
||||||
const file = Bun.file(indexPath);
|
const file = Bun.file(indexPath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const blob = await file.arrayBuffer();
|
const blob = await file.arrayBuffer();
|
||||||
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
|
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to simple HTML if admin panel is not built
|
// Fallback to simple HTML if admin panel is not built
|
||||||
return c.html(`
|
return c.html(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -606,7 +652,9 @@ serve(
|
|||||||
},
|
},
|
||||||
(info) => {
|
(info) => {
|
||||||
console.log(`🔧 Admin panel running on http://localhost:${info.port}`);
|
console.log(`🔧 Admin panel running on http://localhost:${info.port}`);
|
||||||
console.log(`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`);
|
console.log(
|
||||||
|
`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`,
|
||||||
|
);
|
||||||
console.log(`🗄️ Database: ${config.paths.dbPath}`);
|
console.log(`🗄️ Database: ${config.paths.dbPath}`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
77
biome.json
Normal file
77
biome.json
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"data",
|
||||||
|
"public/podcast_audio",
|
||||||
|
"frontend/dist",
|
||||||
|
"admin-panel/dist"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"formatWithErrors": false,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 80,
|
||||||
|
"attributePosition": "auto"
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"quoteProperties": "asNeeded",
|
||||||
|
"trailingCommas": "all",
|
||||||
|
"semicolons": "always",
|
||||||
|
"arrowParentheses": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"attributePosition": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
69
bun.lock
69
bun.lock
@ -7,6 +7,7 @@
|
|||||||
"@aws-sdk/client-polly": "^3.823.0",
|
"@aws-sdk/client-polly": "^3.823.0",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@types/xml2js": "^0.4.14",
|
"@types/xml2js": "^0.4.14",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"hono": "^4.7.11",
|
"hono": "^4.7.11",
|
||||||
"openai": "^4.104.0",
|
"openai": "^4.104.0",
|
||||||
@ -17,7 +18,9 @@
|
|||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/cheerio": "^1.0.0",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
@ -123,6 +126,24 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="],
|
"@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="],
|
||||||
|
|
||||||
|
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
|
||||||
|
|
||||||
"@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="],
|
"@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
|
||||||
@ -317,6 +338,8 @@
|
|||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
|
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
|
||||||
|
|
||||||
|
"@types/cheerio": ["@types/cheerio@1.0.0", "", { "dependencies": { "cheerio": "*" } }, "sha512-zAaImHWoh5RY2CLgU2mvg3bl2k3F65B0N5yphuII3ythFLPmJhL7sj1RDu6gSxcgqHlETbr/lhA2OBY+WF1fXQ=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="],
|
"@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="],
|
||||||
@ -339,6 +362,8 @@
|
|||||||
|
|
||||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
|
|
||||||
"bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="],
|
"bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.25.0", "", { "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA=="],
|
"browserslist": ["browserslist@4.25.0", "", { "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA=="],
|
||||||
@ -353,6 +378,10 @@
|
|||||||
|
|
||||||
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
|
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
|
||||||
|
|
||||||
|
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
|
||||||
|
|
||||||
|
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
|
||||||
|
|
||||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
|
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
|
||||||
@ -361,16 +390,30 @@
|
|||||||
|
|
||||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||||
|
|
||||||
|
"css-select": ["css-select@5.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg=="],
|
||||||
|
|
||||||
|
"css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||||
|
|
||||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
|
|
||||||
|
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.162", "", {}, "sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.162", "", {}, "sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA=="],
|
||||||
|
|
||||||
|
"encoding-sniffer": ["encoding-sniffer@0.2.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg=="],
|
||||||
|
|
||||||
"entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
|
"entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
@ -423,12 +466,16 @@
|
|||||||
|
|
||||||
"hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="],
|
"hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="],
|
||||||
|
|
||||||
|
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||||
|
|
||||||
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
|
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
|
||||||
|
|
||||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||||
|
|
||||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
@ -455,10 +502,18 @@
|
|||||||
|
|
||||||
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
||||||
|
|
||||||
|
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||||
|
|
||||||
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
||||||
|
|
||||||
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
|
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
|
||||||
|
|
||||||
|
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
|
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||||
|
|
||||||
|
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||||
@ -485,6 +540,8 @@
|
|||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||||
@ -509,6 +566,8 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||||
|
|
||||||
|
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||||
@ -523,6 +582,10 @@
|
|||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||||
|
|
||||||
|
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||||
|
|
||||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||||
@ -539,10 +602,16 @@
|
|||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
|
"bun-types/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
|
||||||
|
|
||||||
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
|
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
|
||||||
|
|
||||||
"openai/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
|
"openai/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
|
||||||
|
|
||||||
|
"parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
|
||||||
|
|
||||||
"rss-parser/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
"rss-parser/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||||
|
|
||||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||||
|
@ -1,28 +1,28 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js";
|
||||||
import globals from 'globals'
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist'] },
|
{ ignores: ["dist"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
"react-hooks": reactHooks,
|
||||||
'react-refresh': reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': [
|
"react-refresh/only-export-components": [
|
||||||
'warn',
|
"warn",
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import { Routes, Route, Link, useLocation } from 'react-router-dom'
|
import { Routes, Route, Link, useLocation } from "react-router-dom";
|
||||||
import EpisodeList from './components/EpisodeList'
|
import EpisodeList from "./components/EpisodeList";
|
||||||
import FeedManager from './components/FeedManager'
|
import FeedManager from "./components/FeedManager";
|
||||||
import FeedList from './components/FeedList'
|
import FeedList from "./components/FeedList";
|
||||||
import FeedDetail from './components/FeedDetail'
|
import FeedDetail from "./components/FeedDetail";
|
||||||
import EpisodeDetail from './components/EpisodeDetail'
|
import EpisodeDetail from "./components/EpisodeDetail";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const isMainPage = ['/', '/feeds', '/feed-requests'].includes(location.pathname)
|
const isMainPage = ["/", "/feeds", "/feed-requests"].includes(
|
||||||
|
location.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -15,25 +17,27 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<div className="title">Voice RSS Summary</div>
|
<div className="title">Voice RSS Summary</div>
|
||||||
<div className="subtitle">RSS フィードから自動生成された音声ポッドキャスト</div>
|
<div className="subtitle">
|
||||||
|
RSS フィードから自動生成された音声ポッドキャスト
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className={`tab ${location.pathname === '/' ? 'active' : ''}`}
|
className={`tab ${location.pathname === "/" ? "active" : ""}`}
|
||||||
>
|
>
|
||||||
エピソード一覧
|
エピソード一覧
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/feeds"
|
to="/feeds"
|
||||||
className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
|
className={`tab ${location.pathname === "/feeds" ? "active" : ""}`}
|
||||||
>
|
>
|
||||||
フィード一覧
|
フィード一覧
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/feed-requests"
|
to="/feed-requests"
|
||||||
className={`tab ${location.pathname === '/feed-requests' ? 'active' : ''}`}
|
className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
|
||||||
>
|
>
|
||||||
フィードリクエスト
|
フィードリクエスト
|
||||||
</Link>
|
</Link>
|
||||||
@ -51,7 +55,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
@ -1,59 +1,58 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
|
||||||
|
|
||||||
interface EpisodeWithFeedInfo {
|
interface EpisodeWithFeedInfo {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
description?: string
|
description?: string;
|
||||||
audioPath: string
|
audioPath: string;
|
||||||
duration?: number
|
duration?: number;
|
||||||
fileSize?: number
|
fileSize?: number;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
articleId: string
|
articleId: string;
|
||||||
articleTitle: string
|
articleTitle: string;
|
||||||
articleLink: string
|
articleLink: string;
|
||||||
articlePubDate: string
|
articlePubDate: string;
|
||||||
feedId: string
|
feedId: string;
|
||||||
feedTitle?: string
|
feedTitle?: string;
|
||||||
feedUrl: string
|
feedUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EpisodeDetail() {
|
function EpisodeDetail() {
|
||||||
const { episodeId } = useParams<{ episodeId: string }>()
|
const { episodeId } = useParams<{ episodeId: string }>();
|
||||||
const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null)
|
const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null);
|
||||||
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 [useDatabase, setUseDatabase] = useState(true)
|
const [useDatabase, setUseDatabase] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEpisode()
|
fetchEpisode();
|
||||||
}, [episodeId, useDatabase])
|
}, [episodeId, useDatabase]);
|
||||||
|
|
||||||
const fetchEpisode = async () => {
|
const fetchEpisode = async () => {
|
||||||
if (!episodeId) return
|
if (!episodeId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
|
|
||||||
if (useDatabase) {
|
if (useDatabase) {
|
||||||
// Try to fetch from database with source info first
|
// Try to fetch from database with source info first
|
||||||
const response = await fetch(`/api/episode-with-source/${episodeId}`)
|
const response = await fetch(`/api/episode-with-source/${episodeId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('データベースからの取得に失敗しました')
|
throw new Error("データベースからの取得に失敗しました");
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
setEpisode(data.episode)
|
setEpisode(data.episode);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to XML parsing (existing functionality)
|
// Fallback to XML parsing (existing functionality)
|
||||||
const response = await fetch(`/api/episode/${episodeId}`)
|
const response = await fetch(`/api/episode/${episodeId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
throw new Error(errorData.error || "エピソードの取得に失敗しました");
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
const xmlEpisode = data.episode
|
const xmlEpisode = data.episode;
|
||||||
|
|
||||||
// Convert XML episode to EpisodeWithFeedInfo format
|
// Convert XML episode to EpisodeWithFeedInfo format
|
||||||
const convertedEpisode: EpisodeWithFeedInfo = {
|
const convertedEpisode: EpisodeWithFeedInfo = {
|
||||||
id: xmlEpisode.id,
|
id: xmlEpisode.id,
|
||||||
@ -65,128 +64,146 @@ function EpisodeDetail() {
|
|||||||
articleTitle: xmlEpisode.title,
|
articleTitle: xmlEpisode.title,
|
||||||
articleLink: xmlEpisode.link,
|
articleLink: xmlEpisode.link,
|
||||||
articlePubDate: xmlEpisode.pubDate,
|
articlePubDate: xmlEpisode.pubDate,
|
||||||
feedId: '',
|
feedId: "",
|
||||||
feedTitle: 'RSS Feed',
|
feedTitle: "RSS Feed",
|
||||||
feedUrl: ''
|
feedUrl: "",
|
||||||
}
|
};
|
||||||
setEpisode(convertedEpisode)
|
setEpisode(convertedEpisode);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Episode fetch error:', err)
|
console.error("Episode fetch error:", err);
|
||||||
if (useDatabase) {
|
if (useDatabase) {
|
||||||
// Fallback to XML if database fails
|
// Fallback to XML if database fails
|
||||||
console.log('Falling back to XML parsing...')
|
console.log("Falling back to XML parsing...");
|
||||||
setUseDatabase(false)
|
setUseDatabase(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const shareEpisode = () => {
|
const shareEpisode = () => {
|
||||||
const shareUrl = window.location.href
|
const shareUrl = window.location.href;
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard
|
||||||
alert('エピソードリンクをクリップボードにコピーしました')
|
.writeText(shareUrl)
|
||||||
}).catch(() => {
|
.then(() => {
|
||||||
// Fallback for older browsers
|
alert("エピソードリンクをクリップボードにコピーしました");
|
||||||
const textArea = document.createElement('textarea')
|
})
|
||||||
textArea.value = shareUrl
|
.catch(() => {
|
||||||
document.body.appendChild(textArea)
|
// Fallback for older browsers
|
||||||
textArea.select()
|
const textArea = document.createElement("textarea");
|
||||||
document.execCommand('copy')
|
textArea.value = shareUrl;
|
||||||
document.body.removeChild(textArea)
|
document.body.appendChild(textArea);
|
||||||
alert('エピソードリンクをクリップボードにコピーしました')
|
textArea.select();
|
||||||
})
|
document.execCommand("copy");
|
||||||
}
|
document.body.removeChild(textArea);
|
||||||
|
alert("エピソードリンクをクリップボードにコピーしました");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString('ja-JP')
|
return new Date(dateString).toLocaleString("ja-JP");
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number) => {
|
const formatFileSize = (bytes?: number) => {
|
||||||
if (!bytes) return ''
|
if (!bytes) return "";
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
let unitIndex = 0
|
let unitIndex = 0;
|
||||||
let fileSize = bytes
|
let fileSize = bytes;
|
||||||
|
|
||||||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||||
fileSize /= 1024
|
fileSize /= 1024;
|
||||||
unitIndex++
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="loading">読み込み中...</div>
|
<div className="loading">読み込み中...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="error">{error}</div>
|
<div className="error">{error}</div>
|
||||||
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: "20px" }}
|
||||||
|
>
|
||||||
エピソード一覧に戻る
|
エピソード一覧に戻る
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="error">エピソードが見つかりません</div>
|
<div className="error">エピソードが見つかりません</div>
|
||||||
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: "20px" }}
|
||||||
|
>
|
||||||
エピソード一覧に戻る
|
エピソード一覧に戻る
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<Link to="/" className="btn btn-secondary" style={{ marginBottom: '20px' }}>
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginBottom: "20px" }}
|
||||||
|
>
|
||||||
← エピソード一覧に戻る
|
← エピソード一覧に戻る
|
||||||
</Link>
|
</Link>
|
||||||
<div className="title" style={{ fontSize: '28px', marginBottom: '10px' }}>
|
<div
|
||||||
|
className="title"
|
||||||
|
style={{ fontSize: "28px", marginBottom: "10px" }}
|
||||||
|
>
|
||||||
{episode.title}
|
{episode.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
|
<div
|
||||||
|
className="subtitle"
|
||||||
|
style={{ color: "#666", marginBottom: "20px" }}
|
||||||
|
>
|
||||||
作成日: {formatDate(episode.createdAt)}
|
作成日: {formatDate(episode.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<div style={{ marginBottom: '30px' }}>
|
<div style={{ marginBottom: "30px" }}>
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
className="audio-player"
|
className="audio-player"
|
||||||
src={episode.audioPath}
|
src={episode.audioPath}
|
||||||
style={{ width: '100%', height: '60px' }}
|
style={{ width: "100%", height: "60px" }}
|
||||||
>
|
>
|
||||||
お使いのブラウザは音声の再生に対応していません。
|
お使いのブラウザは音声の再生に対応していません。
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '15px', marginBottom: '30px' }}>
|
<div style={{ display: "flex", gap: "15px", marginBottom: "30px" }}>
|
||||||
<button
|
<button className="btn btn-primary" onClick={shareEpisode}>
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={shareEpisode}
|
|
||||||
>
|
|
||||||
このエピソードを共有
|
このエピソードを共有
|
||||||
</button>
|
</button>
|
||||||
{episode.articleLink && (
|
{episode.articleLink && (
|
||||||
<a
|
<a
|
||||||
href={episode.articleLink}
|
href={episode.articleLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
>
|
>
|
||||||
@ -194,62 +211,77 @@ function EpisodeDetail() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{episode.feedId && (
|
{episode.feedId && (
|
||||||
<Link
|
<Link to={`/feeds/${episode.feedId}`} className="btn btn-secondary">
|
||||||
to={`/feeds/${episode.feedId}`}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
このフィードの他のエピソード
|
このフィードの他のエピソード
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '30px' }}>
|
<div style={{ marginBottom: "30px" }}>
|
||||||
<h3 style={{ marginBottom: '15px' }}>エピソード情報</h3>
|
<h3 style={{ marginBottom: "15px" }}>エピソード情報</h3>
|
||||||
<div style={{
|
<div
|
||||||
backgroundColor: '#f8f9fa',
|
style={{
|
||||||
padding: '20px',
|
backgroundColor: "#f8f9fa",
|
||||||
borderRadius: '8px',
|
padding: "20px",
|
||||||
fontSize: '14px'
|
borderRadius: "8px",
|
||||||
}}>
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{episode.feedTitle && (
|
{episode.feedTitle && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: "10px" }}>
|
||||||
<strong>ソースフィード:</strong>
|
<strong>ソースフィード:</strong>
|
||||||
<Link to={`/feeds/${episode.feedId}`} style={{ marginLeft: '5px', color: '#007bff' }}>
|
<Link
|
||||||
|
to={`/feeds/${episode.feedId}`}
|
||||||
|
style={{ marginLeft: "5px", color: "#007bff" }}
|
||||||
|
>
|
||||||
{episode.feedTitle}
|
{episode.feedTitle}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{episode.feedUrl && (
|
{episode.feedUrl && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: "10px" }}>
|
||||||
<strong>フィードURL:</strong>
|
<strong>フィードURL:</strong>
|
||||||
<a href={episode.feedUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
|
<a
|
||||||
|
href={episode.feedUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ marginLeft: "5px" }}
|
||||||
|
>
|
||||||
{episode.feedUrl}
|
{episode.feedUrl}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{episode.articleTitle && episode.articleTitle !== episode.title && (
|
{episode.articleTitle && episode.articleTitle !== episode.title && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: "10px" }}>
|
||||||
<strong>元記事タイトル:</strong> {episode.articleTitle}
|
<strong>元記事タイトル:</strong> {episode.articleTitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{episode.articlePubDate && (
|
{episode.articlePubDate && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: "10px" }}>
|
||||||
<strong>記事公開日:</strong> {formatDate(episode.articlePubDate)}
|
<strong>記事公開日:</strong>{" "}
|
||||||
|
{formatDate(episode.articlePubDate)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{episode.fileSize && (
|
{episode.fileSize && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: "10px" }}>
|
||||||
<strong>ファイルサイズ:</strong> {formatFileSize(episode.fileSize)}
|
<strong>ファイルサイズ:</strong>{" "}
|
||||||
|
{formatFileSize(episode.fileSize)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{episode.duration && (
|
{episode.duration && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: "10px" }}>
|
||||||
<strong>再生時間:</strong> {Math.floor(episode.duration / 60)}分{episode.duration % 60}秒
|
<strong>再生時間:</strong> {Math.floor(episode.duration / 60)}分
|
||||||
|
{episode.duration % 60}秒
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>音声URL:</strong>
|
<strong>音声URL:</strong>
|
||||||
<a href={episode.audioPath} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
|
<a
|
||||||
|
href={episode.audioPath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ marginLeft: "5px" }}
|
||||||
|
>
|
||||||
直接ダウンロード
|
直接ダウンロード
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -258,21 +290,23 @@ function EpisodeDetail() {
|
|||||||
|
|
||||||
{episode.description && (
|
{episode.description && (
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ marginBottom: '15px' }}>エピソード詳細</h3>
|
<h3 style={{ marginBottom: "15px" }}>エピソード詳細</h3>
|
||||||
<div style={{
|
<div
|
||||||
backgroundColor: '#fff',
|
style={{
|
||||||
padding: '20px',
|
backgroundColor: "#fff",
|
||||||
border: '1px solid #e9ecef',
|
padding: "20px",
|
||||||
borderRadius: '8px',
|
border: "1px solid #e9ecef",
|
||||||
lineHeight: '1.6'
|
borderRadius: "8px",
|
||||||
}}>
|
lineHeight: "1.6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{episode.description}
|
{episode.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EpisodeDetail
|
export default EpisodeDetail;
|
||||||
|
@ -1,186 +1,202 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
interface Episode {
|
interface Episode {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
pubDate: string
|
pubDate: string;
|
||||||
audioUrl: string
|
audioUrl: string;
|
||||||
audioLength: string
|
audioLength: string;
|
||||||
guid: string
|
guid: string;
|
||||||
link: string
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EpisodeWithFeedInfo {
|
interface EpisodeWithFeedInfo {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
description?: string
|
description?: string;
|
||||||
audioPath: string
|
audioPath: string;
|
||||||
duration?: number
|
duration?: number;
|
||||||
fileSize?: number
|
fileSize?: number;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
articleId: string
|
articleId: string;
|
||||||
articleTitle: string
|
articleTitle: string;
|
||||||
articleLink: string
|
articleLink: string;
|
||||||
articlePubDate: string
|
articlePubDate: string;
|
||||||
feedId: string
|
feedId: string;
|
||||||
feedTitle?: string
|
feedTitle?: string;
|
||||||
feedUrl: string
|
feedUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EpisodeList() {
|
function EpisodeList() {
|
||||||
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
|
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
|
||||||
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 [currentAudio, setCurrentAudio] = useState<string | null>(null)
|
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
|
||||||
const [useDatabase, setUseDatabase] = useState(true)
|
const [useDatabase, setUseDatabase] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEpisodes()
|
fetchEpisodes();
|
||||||
}, [useDatabase])
|
}, [useDatabase]);
|
||||||
|
|
||||||
const fetchEpisodes = async () => {
|
const fetchEpisodes = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
if (useDatabase) {
|
if (useDatabase) {
|
||||||
// Try to fetch from database first
|
// Try to fetch from database first
|
||||||
const response = await fetch('/api/episodes-with-feed-info')
|
const response = await fetch("/api/episodes-with-feed-info");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('データベースからの取得に失敗しました')
|
throw new Error("データベースからの取得に失敗しました");
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
const dbEpisodes = data.episodes || []
|
const dbEpisodes = data.episodes || [];
|
||||||
|
|
||||||
if (dbEpisodes.length === 0) {
|
if (dbEpisodes.length === 0) {
|
||||||
// Database is empty, fallback to XML
|
// Database is empty, fallback to XML
|
||||||
console.log('Database is empty, falling back to XML parsing...')
|
console.log("Database is empty, falling back to XML parsing...");
|
||||||
setUseDatabase(false)
|
setUseDatabase(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEpisodes(dbEpisodes)
|
setEpisodes(dbEpisodes);
|
||||||
} else {
|
} else {
|
||||||
// Use XML parsing as primary source
|
// Use XML parsing as primary source
|
||||||
const response = await fetch('/api/episodes-from-xml')
|
const response = await fetch("/api/episodes-from-xml");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
throw new Error(errorData.error || "エピソードの取得に失敗しました");
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
console.log('Fetched episodes from XML:', data)
|
console.log("Fetched episodes from XML:", data);
|
||||||
|
|
||||||
// Convert XML episodes to EpisodeWithFeedInfo format
|
// Convert XML episodes to EpisodeWithFeedInfo format
|
||||||
const xmlEpisodes = data.episodes || []
|
const xmlEpisodes = data.episodes || [];
|
||||||
const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map((episode: Episode) => ({
|
const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map(
|
||||||
id: episode.id,
|
(episode: Episode) => ({
|
||||||
title: episode.title,
|
id: episode.id,
|
||||||
description: episode.description,
|
title: episode.title,
|
||||||
audioPath: episode.audioUrl,
|
description: episode.description,
|
||||||
createdAt: episode.pubDate,
|
audioPath: episode.audioUrl,
|
||||||
articleId: episode.guid,
|
createdAt: episode.pubDate,
|
||||||
articleTitle: episode.title,
|
articleId: episode.guid,
|
||||||
articleLink: episode.link,
|
articleTitle: episode.title,
|
||||||
articlePubDate: episode.pubDate,
|
articleLink: episode.link,
|
||||||
feedId: '',
|
articlePubDate: episode.pubDate,
|
||||||
feedTitle: 'RSS Feed',
|
feedId: "",
|
||||||
feedUrl: ''
|
feedTitle: "RSS Feed",
|
||||||
}))
|
feedUrl: "",
|
||||||
setEpisodes(convertedEpisodes)
|
}),
|
||||||
|
);
|
||||||
|
setEpisodes(convertedEpisodes);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Episode fetch error:', err)
|
console.error("Episode fetch error:", err);
|
||||||
if (useDatabase) {
|
if (useDatabase) {
|
||||||
// Fallback to XML if database fails
|
// Fallback to XML if database fails
|
||||||
console.log('Falling back to XML parsing...')
|
console.log("Falling back to XML parsing...");
|
||||||
setUseDatabase(false)
|
setUseDatabase(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString('ja-JP')
|
return new Date(dateString).toLocaleString("ja-JP");
|
||||||
}
|
};
|
||||||
|
|
||||||
const playAudio = (audioPath: string) => {
|
const playAudio = (audioPath: string) => {
|
||||||
if (currentAudio) {
|
if (currentAudio) {
|
||||||
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
|
const currentPlayer = document.getElementById(
|
||||||
|
currentAudio,
|
||||||
|
) as HTMLAudioElement;
|
||||||
if (currentPlayer) {
|
if (currentPlayer) {
|
||||||
currentPlayer.pause()
|
currentPlayer.pause();
|
||||||
currentPlayer.currentTime = 0
|
currentPlayer.currentTime = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCurrentAudio(audioPath)
|
setCurrentAudio(audioPath);
|
||||||
}
|
};
|
||||||
|
|
||||||
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
|
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
|
||||||
const shareUrl = `${window.location.origin}/episode/${episode.id}`
|
const shareUrl = `${window.location.origin}/episode/${episode.id}`;
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard
|
||||||
alert('エピソードリンクをクリップボードにコピーしました')
|
.writeText(shareUrl)
|
||||||
}).catch(() => {
|
.then(() => {
|
||||||
// Fallback for older browsers
|
alert("エピソードリンクをクリップボードにコピーしました");
|
||||||
const textArea = document.createElement('textarea')
|
})
|
||||||
textArea.value = shareUrl
|
.catch(() => {
|
||||||
document.body.appendChild(textArea)
|
// Fallback for older browsers
|
||||||
textArea.select()
|
const textArea = document.createElement("textarea");
|
||||||
document.execCommand('copy')
|
textArea.value = shareUrl;
|
||||||
document.body.removeChild(textArea)
|
document.body.appendChild(textArea);
|
||||||
alert('エピソードリンクをクリップボードにコピーしました')
|
textArea.select();
|
||||||
})
|
document.execCommand("copy");
|
||||||
}
|
document.body.removeChild(textArea);
|
||||||
|
alert("エピソードリンクをクリップボードにコピーしました");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number) => {
|
const formatFileSize = (bytes?: number) => {
|
||||||
if (!bytes) return ''
|
if (!bytes) return "";
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
let unitIndex = 0
|
let unitIndex = 0;
|
||||||
let fileSize = bytes
|
let fileSize = bytes;
|
||||||
|
|
||||||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||||
fileSize /= 1024
|
fileSize /= 1024;
|
||||||
unitIndex++
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">読み込み中...</div>
|
return <div className="loading">読み込み中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="error">{error}</div>
|
return <div className="error">{error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (episodes.length === 0) {
|
if (episodes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>エピソードがありません</p>
|
<p>エピソードがありません</p>
|
||||||
<p>フィードリクエストでRSSフィードをリクエストするか、管理者にバッチ処理の実行を依頼してください</p>
|
<p>
|
||||||
<button
|
フィードリクエストでRSSフィードをリクエストするか、管理者にバッチ処理の実行を依頼してください
|
||||||
className="btn btn-secondary"
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
onClick={fetchEpisodes}
|
onClick={fetchEpisodes}
|
||||||
style={{ marginTop: '10px' }}
|
style={{ marginTop: "10px" }}
|
||||||
>
|
>
|
||||||
再読み込み
|
再読み込み
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "20px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h2>エピソード一覧 ({episodes.length}件)</h2>
|
<h2>エピソード一覧 ({episodes.length}件)</h2>
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
||||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
<span style={{ fontSize: "12px", color: "#666" }}>
|
||||||
データソース: {useDatabase ? 'データベース' : 'XML'}
|
データソース: {useDatabase ? "データベース" : "XML"}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-secondary" onClick={fetchEpisodes}>
|
<button className="btn btn-secondary" onClick={fetchEpisodes}>
|
||||||
更新
|
更新
|
||||||
@ -191,74 +207,107 @@ function EpisodeList() {
|
|||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '35%' }}>タイトル</th>
|
<th style={{ width: "35%" }}>タイトル</th>
|
||||||
<th style={{ width: '25%' }}>説明</th>
|
<th style={{ width: "25%" }}>説明</th>
|
||||||
<th style={{ width: '15%' }}>作成日</th>
|
<th style={{ width: "15%" }}>作成日</th>
|
||||||
<th style={{ width: '25%' }}>操作</th>
|
<th style={{ width: "25%" }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{episodes.map((episode) => (
|
{episodes.map((episode) => (
|
||||||
<tr key={episode.id}>
|
<tr key={episode.id}>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{ marginBottom: "8px" }}>
|
||||||
<strong>
|
<strong>
|
||||||
<Link
|
<Link
|
||||||
to={`/episode/${episode.id}`}
|
to={`/episode/${episode.id}`}
|
||||||
style={{ textDecoration: 'none', color: '#007bff' }}
|
style={{ textDecoration: "none", color: "#007bff" }}
|
||||||
>
|
>
|
||||||
{episode.title}
|
{episode.title}
|
||||||
</Link>
|
</Link>
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
{episode.feedTitle && (
|
{episode.feedTitle && (
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
<div
|
||||||
フィード: <Link to={`/feeds/${episode.feedId}`} style={{ color: '#007bff' }}>{episode.feedTitle}</Link>
|
style={{
|
||||||
</div>
|
fontSize: "12px",
|
||||||
)}
|
color: "#666",
|
||||||
{episode.articleTitle && episode.articleTitle !== episode.title && (
|
marginBottom: "4px",
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
}}
|
||||||
元記事: <strong>{episode.articleTitle}</strong>
|
>
|
||||||
|
フィード:{" "}
|
||||||
|
<Link
|
||||||
|
to={`/feeds/${episode.feedId}`}
|
||||||
|
style={{ color: "#007bff" }}
|
||||||
|
>
|
||||||
|
{episode.feedTitle}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{episode.articleTitle &&
|
||||||
|
episode.articleTitle !== episode.title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
元記事: <strong>{episode.articleTitle}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{episode.articleLink && (
|
{episode.articleLink && (
|
||||||
<a
|
<a
|
||||||
href={episode.articleLink}
|
href={episode.articleLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontSize: '12px', color: '#666' }}
|
style={{ fontSize: "12px", color: "#666" }}
|
||||||
>
|
>
|
||||||
元記事を見る
|
元記事を見る
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{
|
<div
|
||||||
fontSize: '14px',
|
style={{
|
||||||
maxWidth: '200px',
|
fontSize: "14px",
|
||||||
overflow: 'hidden',
|
maxWidth: "200px",
|
||||||
textOverflow: 'ellipsis',
|
overflow: "hidden",
|
||||||
whiteSpace: 'nowrap'
|
textOverflow: "ellipsis",
|
||||||
}}>
|
whiteSpace: "nowrap",
|
||||||
{episode.description || 'No description'}
|
}}
|
||||||
|
>
|
||||||
|
{episode.description || "No description"}
|
||||||
</div>
|
</div>
|
||||||
{episode.fileSize && (
|
{episode.fileSize && (
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#666",
|
||||||
|
marginTop: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatFileSize(episode.fileSize)}
|
{formatFileSize(episode.fileSize)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDate(episode.createdAt)}</td>
|
<td>{formatDate(episode.createdAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
|
<div
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
style={{
|
||||||
<button
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => playAudio(episode.audioPath)}
|
onClick={() => playAudio(episode.audioPath)}
|
||||||
>
|
>
|
||||||
再生
|
再生
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => shareEpisode(episode)}
|
onClick={() => shareEpisode(episode)}
|
||||||
>
|
>
|
||||||
@ -283,7 +332,7 @@ function EpisodeList() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EpisodeList
|
export default EpisodeList;
|
||||||
|
@ -1,180 +1,198 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
|
||||||
interface Feed {
|
interface Feed {
|
||||||
id: string
|
id: string;
|
||||||
url: string
|
url: string;
|
||||||
title?: string
|
title?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
lastUpdated?: string
|
lastUpdated?: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
active: boolean
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EpisodeWithFeedInfo {
|
interface EpisodeWithFeedInfo {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
description?: string
|
description?: string;
|
||||||
audioPath: string
|
audioPath: string;
|
||||||
duration?: number
|
duration?: number;
|
||||||
fileSize?: number
|
fileSize?: number;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
articleId: string
|
articleId: string;
|
||||||
articleTitle: string
|
articleTitle: string;
|
||||||
articleLink: string
|
articleLink: string;
|
||||||
articlePubDate: string
|
articlePubDate: string;
|
||||||
feedId: string
|
feedId: string;
|
||||||
feedTitle?: string
|
feedTitle?: string;
|
||||||
feedUrl: string
|
feedUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedDetail() {
|
function FeedDetail() {
|
||||||
const { feedId } = useParams<{ feedId: string }>()
|
const { feedId } = useParams<{ feedId: string }>();
|
||||||
const [feed, setFeed] = useState<Feed | null>(null)
|
const [feed, setFeed] = useState<Feed | null>(null);
|
||||||
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
|
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
|
||||||
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 [currentAudio, setCurrentAudio] = useState<string | null>(null)
|
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedId) {
|
if (feedId) {
|
||||||
fetchFeedAndEpisodes()
|
fetchFeedAndEpisodes();
|
||||||
}
|
}
|
||||||
}, [feedId])
|
}, [feedId]);
|
||||||
|
|
||||||
const fetchFeedAndEpisodes = async () => {
|
const fetchFeedAndEpisodes = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
|
|
||||||
// Fetch feed info and episodes in parallel
|
// Fetch feed info and episodes in parallel
|
||||||
const [feedResponse, episodesResponse] = await Promise.all([
|
const [feedResponse, episodesResponse] = await Promise.all([
|
||||||
fetch(`/api/feeds/${feedId}`),
|
fetch(`/api/feeds/${feedId}`),
|
||||||
fetch(`/api/feeds/${feedId}/episodes`)
|
fetch(`/api/feeds/${feedId}/episodes`),
|
||||||
])
|
]);
|
||||||
|
|
||||||
if (!feedResponse.ok) {
|
if (!feedResponse.ok) {
|
||||||
const errorData = await feedResponse.json()
|
const errorData = await feedResponse.json();
|
||||||
throw new Error(errorData.error || 'フィード情報の取得に失敗しました')
|
throw new Error(errorData.error || "フィード情報の取得に失敗しました");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!episodesResponse.ok) {
|
if (!episodesResponse.ok) {
|
||||||
const errorData = await episodesResponse.json()
|
const errorData = await episodesResponse.json();
|
||||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
throw new Error(errorData.error || "エピソードの取得に失敗しました");
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedData = await feedResponse.json()
|
const feedData = await feedResponse.json();
|
||||||
const episodesData = await episodesResponse.json()
|
const episodesData = await episodesResponse.json();
|
||||||
|
|
||||||
setFeed(feedData.feed)
|
setFeed(feedData.feed);
|
||||||
setEpisodes(episodesData.episodes || [])
|
setEpisodes(episodesData.episodes || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Feed detail fetch error:', err)
|
console.error("Feed detail fetch error:", err);
|
||||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString('ja-JP')
|
return new Date(dateString).toLocaleString("ja-JP");
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number) => {
|
const formatFileSize = (bytes?: number) => {
|
||||||
if (!bytes) return ''
|
if (!bytes) return "";
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
let unitIndex = 0
|
let unitIndex = 0;
|
||||||
let fileSize = bytes
|
let fileSize = bytes;
|
||||||
|
|
||||||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||||
fileSize /= 1024
|
fileSize /= 1024;
|
||||||
unitIndex++
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const playAudio = (audioPath: string) => {
|
const playAudio = (audioPath: string) => {
|
||||||
if (currentAudio) {
|
if (currentAudio) {
|
||||||
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
|
const currentPlayer = document.getElementById(
|
||||||
|
currentAudio,
|
||||||
|
) as HTMLAudioElement;
|
||||||
if (currentPlayer) {
|
if (currentPlayer) {
|
||||||
currentPlayer.pause()
|
currentPlayer.pause();
|
||||||
currentPlayer.currentTime = 0
|
currentPlayer.currentTime = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCurrentAudio(audioPath)
|
setCurrentAudio(audioPath);
|
||||||
}
|
};
|
||||||
|
|
||||||
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
|
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
|
||||||
const shareUrl = `${window.location.origin}/episode/${episode.id}`
|
const shareUrl = `${window.location.origin}/episode/${episode.id}`;
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard
|
||||||
alert('エピソードリンクをクリップボードにコピーしました')
|
.writeText(shareUrl)
|
||||||
}).catch(() => {
|
.then(() => {
|
||||||
const textArea = document.createElement('textarea')
|
alert("エピソードリンクをクリップボードにコピーしました");
|
||||||
textArea.value = shareUrl
|
})
|
||||||
document.body.appendChild(textArea)
|
.catch(() => {
|
||||||
textArea.select()
|
const textArea = document.createElement("textarea");
|
||||||
document.execCommand('copy')
|
textArea.value = shareUrl;
|
||||||
document.body.removeChild(textArea)
|
document.body.appendChild(textArea);
|
||||||
alert('エピソードリンクをクリップボードにコピーしました')
|
textArea.select();
|
||||||
})
|
document.execCommand("copy");
|
||||||
}
|
document.body.removeChild(textArea);
|
||||||
|
alert("エピソードリンクをクリップボードにコピーしました");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">読み込み中...</div>
|
return <div className="loading">読み込み中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="error">
|
<div className="error">
|
||||||
{error}
|
{error}
|
||||||
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
|
<Link
|
||||||
|
to="/feeds"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: "20px", display: "block" }}
|
||||||
|
>
|
||||||
フィード一覧に戻る
|
フィード一覧に戻る
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
return (
|
return (
|
||||||
<div className="error">
|
<div className="error">
|
||||||
フィードが見つかりません
|
フィードが見つかりません
|
||||||
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
|
<Link
|
||||||
|
to="/feeds"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: "20px", display: "block" }}
|
||||||
|
>
|
||||||
フィード一覧に戻る
|
フィード一覧に戻る
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: "20px" }}>
|
||||||
<Link to="/feeds" className="btn btn-secondary">
|
<Link to="/feeds" className="btn btn-secondary">
|
||||||
← フィード一覧に戻る
|
← フィード一覧に戻る
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="feed-header" style={{ marginBottom: '30px' }}>
|
<div className="feed-header" style={{ marginBottom: "30px" }}>
|
||||||
<h1 style={{ marginBottom: '10px' }}>
|
<h1 style={{ marginBottom: "10px" }}>{feed.title || feed.url}</h1>
|
||||||
{feed.title || feed.url}
|
<div style={{ color: "#666", marginBottom: "10px" }}>
|
||||||
</h1>
|
|
||||||
<div style={{ color: '#666', marginBottom: '10px' }}>
|
|
||||||
<a href={feed.url} target="_blank" rel="noopener noreferrer">
|
<a href={feed.url} target="_blank" rel="noopener noreferrer">
|
||||||
{feed.url}
|
{feed.url}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{feed.description && (
|
{feed.description && (
|
||||||
<div style={{ marginBottom: '15px', color: '#333' }}>
|
<div style={{ marginBottom: "15px", color: "#333" }}>
|
||||||
{feed.description}
|
{feed.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
<div style={{ fontSize: "14px", color: "#666" }}>
|
||||||
作成日: {formatDate(feed.createdAt)}
|
作成日: {formatDate(feed.createdAt)}
|
||||||
{feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`}
|
{feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "20px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h2>エピソード一覧 ({episodes.length}件)</h2>
|
<h2>エピソード一覧 ({episodes.length}件)</h2>
|
||||||
<button className="btn btn-secondary" onClick={fetchFeedAndEpisodes}>
|
<button className="btn btn-secondary" onClick={fetchFeedAndEpisodes}>
|
||||||
更新
|
更新
|
||||||
@ -190,67 +208,87 @@ function FeedDetail() {
|
|||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '35%' }}>タイトル</th>
|
<th style={{ width: "35%" }}>タイトル</th>
|
||||||
<th style={{ width: '25%' }}>説明</th>
|
<th style={{ width: "25%" }}>説明</th>
|
||||||
<th style={{ width: '15%' }}>作成日</th>
|
<th style={{ width: "15%" }}>作成日</th>
|
||||||
<th style={{ width: '25%' }}>操作</th>
|
<th style={{ width: "25%" }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{episodes.map((episode) => (
|
{episodes.map((episode) => (
|
||||||
<tr key={episode.id}>
|
<tr key={episode.id}>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{ marginBottom: "8px" }}>
|
||||||
<strong>
|
<strong>
|
||||||
<Link
|
<Link
|
||||||
to={`/episode/${episode.id}`}
|
to={`/episode/${episode.id}`}
|
||||||
style={{ textDecoration: 'none', color: '#007bff' }}
|
style={{ textDecoration: "none", color: "#007bff" }}
|
||||||
>
|
>
|
||||||
{episode.title}
|
{episode.title}
|
||||||
</Link>
|
</Link>
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
元記事: <strong>{episode.articleTitle}</strong>
|
元記事: <strong>{episode.articleTitle}</strong>
|
||||||
</div>
|
</div>
|
||||||
{episode.articleLink && (
|
{episode.articleLink && (
|
||||||
<a
|
<a
|
||||||
href={episode.articleLink}
|
href={episode.articleLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontSize: '12px', color: '#666' }}
|
style={{ fontSize: "12px", color: "#666" }}
|
||||||
>
|
>
|
||||||
元記事を見る
|
元記事を見る
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{
|
<div
|
||||||
fontSize: '14px',
|
style={{
|
||||||
maxWidth: '200px',
|
fontSize: "14px",
|
||||||
overflow: 'hidden',
|
maxWidth: "200px",
|
||||||
textOverflow: 'ellipsis',
|
overflow: "hidden",
|
||||||
whiteSpace: 'nowrap'
|
textOverflow: "ellipsis",
|
||||||
}}>
|
whiteSpace: "nowrap",
|
||||||
{episode.description || 'No description'}
|
}}
|
||||||
|
>
|
||||||
|
{episode.description || "No description"}
|
||||||
</div>
|
</div>
|
||||||
{episode.fileSize && (
|
{episode.fileSize && (
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#666",
|
||||||
|
marginTop: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatFileSize(episode.fileSize)}
|
{formatFileSize(episode.fileSize)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDate(episode.createdAt)}</td>
|
<td>{formatDate(episode.createdAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
|
<div
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
style={{
|
||||||
<button
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => playAudio(episode.audioPath)}
|
onClick={() => playAudio(episode.audioPath)}
|
||||||
>
|
>
|
||||||
再生
|
再生
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => shareEpisode(episode)}
|
onClick={() => shareEpisode(episode)}
|
||||||
>
|
>
|
||||||
@ -276,7 +314,7 @@ function FeedDetail() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeedDetail
|
export default FeedDetail;
|
||||||
|
@ -1,74 +1,83 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
interface Feed {
|
interface Feed {
|
||||||
id: string
|
id: string;
|
||||||
url: string
|
url: string;
|
||||||
title?: string
|
title?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
lastUpdated?: string
|
lastUpdated?: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
active: boolean
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedList() {
|
function FeedList() {
|
||||||
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);
|
||||||
|
|
||||||
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) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error || 'フィードの取得に失敗しました')
|
throw new Error(errorData.error || "フィードの取得に失敗しました");
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
setFeeds(data.feeds || [])
|
setFeeds(data.feeds || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Feed fetch error:', err)
|
console.error("Feed fetch error:", err);
|
||||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString('ja-JP')
|
return new Date(dateString).toLocaleString("ja-JP");
|
||||||
}
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">読み込み中...</div>
|
return <div className="loading">読み込み中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="error">{error}</div>
|
return <div className="error">{error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feeds.length === 0) {
|
if (feeds.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>アクティブなフィードがありません</p>
|
<p>アクティブなフィードがありません</p>
|
||||||
<p>フィードリクエストでRSSフィードをリクエストするか、管理者にフィード追加を依頼してください</p>
|
<p>
|
||||||
<button
|
フィードリクエストでRSSフィードをリクエストするか、管理者にフィード追加を依頼してください
|
||||||
className="btn btn-secondary"
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
onClick={fetchFeeds}
|
onClick={fetchFeeds}
|
||||||
style={{ marginTop: '10px' }}
|
style={{ marginTop: "10px" }}
|
||||||
>
|
>
|
||||||
再読み込み
|
再読み込み
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "20px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h2>フィード一覧 ({feeds.length}件)</h2>
|
<h2>フィード一覧 ({feeds.length}件)</h2>
|
||||||
<button className="btn btn-secondary" onClick={fetchFeeds}>
|
<button className="btn btn-secondary" onClick={fetchFeeds}>
|
||||||
更新
|
更新
|
||||||
@ -90,13 +99,11 @@ function FeedList() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{feed.description && (
|
{feed.description && (
|
||||||
<div className="feed-description">
|
<div className="feed-description">{feed.description}</div>
|
||||||
{feed.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="feed-meta">
|
<div className="feed-meta">
|
||||||
<div>作成日: {formatDate(feed.createdAt)}</div>
|
<div>作成日: {formatDate(feed.createdAt)}</div>
|
||||||
{feed.lastUpdated && (
|
{feed.lastUpdated && (
|
||||||
@ -182,7 +189,7 @@ function FeedList() {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeedList
|
export default FeedList;
|
||||||
|
@ -1,58 +1,60 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
|
|
||||||
function FeedManager() {
|
function FeedManager() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [newFeedUrl, setNewFeedUrl] = useState('')
|
const [newFeedUrl, setNewFeedUrl] = useState("");
|
||||||
const [requestMessage, setRequestMessage] = useState('')
|
const [requestMessage, setRequestMessage] = useState("");
|
||||||
const [requesting, setRequesting] = useState(false)
|
const [requesting, setRequesting] = useState(false);
|
||||||
|
|
||||||
const submitRequest = async (e: React.FormEvent) => {
|
const submitRequest = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
if (!newFeedUrl.trim()) return
|
if (!newFeedUrl.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setRequesting(true)
|
setRequesting(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
setSuccess(null)
|
setSuccess(null);
|
||||||
|
|
||||||
const response = await fetch('/api/feed-requests', {
|
const response = await fetch("/api/feed-requests", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: newFeedUrl.trim(),
|
url: newFeedUrl.trim(),
|
||||||
requestMessage: requestMessage.trim() || undefined
|
requestMessage: requestMessage.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error || 'リクエストの送信に失敗しました')
|
throw new Error(errorData.error || "リクエストの送信に失敗しました");
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess('フィードリクエストを送信しました。管理者の承認をお待ちください。')
|
setSuccess(
|
||||||
setNewFeedUrl('')
|
"フィードリクエストを送信しました。管理者の承認をお待ちください。",
|
||||||
setRequestMessage('')
|
);
|
||||||
|
setNewFeedUrl("");
|
||||||
|
setRequestMessage("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||||
} finally {
|
} finally {
|
||||||
setRequesting(false)
|
setRequesting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
{success && <div className="success">{success}</div>}
|
{success && <div className="success">{success}</div>}
|
||||||
|
|
||||||
<div style={{ marginBottom: '30px' }}>
|
<div style={{ marginBottom: "30px" }}>
|
||||||
<h2>新しいフィードをリクエスト</h2>
|
<h2>新しいフィードをリクエスト</h2>
|
||||||
<p style={{ color: '#666', marginBottom: '20px' }}>
|
<p style={{ color: "#666", marginBottom: "20px" }}>
|
||||||
追加したいRSSフィードのURLを送信してください。管理者が承認後、フィードが追加されます。
|
追加したいRSSフィードのURLを送信してください。管理者が承認後、フィードが追加されます。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={submitRequest}>
|
<form onSubmit={submitRequest}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">RSS フィード URL *</label>
|
<label className="form-label">RSS フィード URL *</label>
|
||||||
@ -65,7 +67,7 @@ function FeedManager() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">メッセージ(任意)</label>
|
<label className="form-label">メッセージ(任意)</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -74,31 +76,39 @@ function FeedManager() {
|
|||||||
onChange={(e) => setRequestMessage(e.target.value)}
|
onChange={(e) => setRequestMessage(e.target.value)}
|
||||||
placeholder="このフィードについての説明や追加理由があれば記載してください"
|
placeholder="このフィードについての説明や追加理由があれば記載してください"
|
||||||
rows={3}
|
rows={3}
|
||||||
style={{ resize: 'vertical', minHeight: '80px' }}
|
style={{ resize: "vertical", minHeight: "80px" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={requesting}
|
disabled={requesting}
|
||||||
>
|
>
|
||||||
{requesting ? 'リクエスト送信中...' : 'フィードをリクエスト'}
|
{requesting ? "リクエスト送信中..." : "フィードをリクエスト"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ backgroundColor: '#f8f9fa', padding: '20px', borderRadius: '8px' }}>
|
<div
|
||||||
<h3 style={{ marginBottom: '15px' }}>フィードリクエストについて</h3>
|
style={{
|
||||||
<ul style={{ paddingLeft: '20px', color: '#666' }}>
|
backgroundColor: "#f8f9fa",
|
||||||
|
padding: "20px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: "15px" }}>フィードリクエストについて</h3>
|
||||||
|
<ul style={{ paddingLeft: "20px", color: "#666" }}>
|
||||||
<li>送信されたフィードリクエストは管理者が確認します</li>
|
<li>送信されたフィードリクエストは管理者が確認します</li>
|
||||||
<li>適切なRSSフィードと判断された場合、承認されて自動的に追加されます</li>
|
<li>
|
||||||
|
適切なRSSフィードと判断された場合、承認されて自動的に追加されます
|
||||||
|
</li>
|
||||||
<li>承認までにお時間をいただく場合があります</li>
|
<li>承認までにお時間をいただく場合があります</li>
|
||||||
<li>不適切なフィードや重複フィードは拒否される場合があります</li>
|
<li>不適切なフィードや重複フィードは拒否される場合があります</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeedManager
|
export default FeedManager;
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
import './styles.css'
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
@ -22,7 +22,7 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@ -41,7 +41,7 @@ body {
|
|||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
@ -75,7 +75,7 @@ body {
|
|||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
@ -159,7 +159,7 @@ body {
|
|||||||
.form-input:focus {
|
.form-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: #007bff;
|
||||||
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-player {
|
.audio-player {
|
||||||
@ -222,4 +222,4 @@ body {
|
|||||||
.feed-actions {
|
.feed-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
11
package.json
11
package.json
@ -7,12 +7,19 @@
|
|||||||
"build:frontend": "cd frontend && bun vite build",
|
"build:frontend": "cd frontend && bun vite build",
|
||||||
"build:admin": "cd admin-panel && bun install && bun run build",
|
"build:admin": "cd admin-panel && bun install && bun run build",
|
||||||
"dev:frontend": "cd frontend && bun vite dev",
|
"dev:frontend": "cd frontend && bun vite dev",
|
||||||
"dev:admin": "cd admin-panel && bun vite dev"
|
"dev:admin": "cd admin-panel && bun vite dev",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"format:check": "biome format .",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"lint:fix": "biome lint --write .",
|
||||||
|
"check": "biome check .",
|
||||||
|
"check:fix": "biome check --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-polly": "^3.823.0",
|
"@aws-sdk/client-polly": "^3.823.0",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@types/xml2js": "^0.4.14",
|
"@types/xml2js": "^0.4.14",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"hono": "^4.7.11",
|
"hono": "^4.7.11",
|
||||||
"openai": "^4.104.0",
|
"openai": "^4.104.0",
|
||||||
@ -24,7 +31,9 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/cheerio": "^1.0.0",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
|
@ -39,7 +39,7 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
|
|||||||
|
|
||||||
// Check for cancellation at start
|
// Check for cancellation at start
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Batch process was cancelled before starting');
|
throw new Error("Batch process was cancelled before starting");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load feed URLs from file
|
// Load feed URLs from file
|
||||||
@ -55,14 +55,17 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
|
|||||||
for (const url of feedUrls) {
|
for (const url of feedUrls) {
|
||||||
// Check for cancellation before processing each feed
|
// Check for cancellation before processing each feed
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Batch process was cancelled during feed processing');
|
throw new Error("Batch process was cancelled during feed processing");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await processFeedUrl(url, abortSignal);
|
await processFeedUrl(url, abortSignal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Re-throw cancellation errors
|
// Re-throw cancellation errors
|
||||||
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.includes("cancelled") || error.name === "AbortError")
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error(`❌ Failed to process feed ${url}:`, error);
|
console.error(`❌ Failed to process feed ${url}:`, error);
|
||||||
@ -72,7 +75,7 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
|
|||||||
|
|
||||||
// Check for cancellation before processing articles
|
// Check for cancellation before processing articles
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Batch process was cancelled before article processing');
|
throw new Error("Batch process was cancelled before article processing");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process unprocessed articles and generate podcasts
|
// Process unprocessed articles and generate podcasts
|
||||||
@ -83,10 +86,13 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
|
|||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.includes("cancelled") || error.name === "AbortError")
|
||||||
|
) {
|
||||||
console.log("🛑 Batch process was cancelled");
|
console.log("🛑 Batch process was cancelled");
|
||||||
const abortError = new Error('Batch process was cancelled');
|
const abortError = new Error("Batch process was cancelled");
|
||||||
abortError.name = 'AbortError';
|
abortError.name = "AbortError";
|
||||||
throw abortError;
|
throw abortError;
|
||||||
}
|
}
|
||||||
console.error("💥 Batch process failed:", error);
|
console.error("💥 Batch process failed:", error);
|
||||||
@ -116,14 +122,17 @@ async function loadFeedUrls(): Promise<string[]> {
|
|||||||
/**
|
/**
|
||||||
* Process a single feed URL and discover new articles
|
* Process a single feed URL and discover new articles
|
||||||
*/
|
*/
|
||||||
async function processFeedUrl(url: string, abortSignal?: AbortSignal): Promise<void> {
|
async function processFeedUrl(
|
||||||
|
url: string,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
if (!url || !url.startsWith("http")) {
|
if (!url || !url.startsWith("http")) {
|
||||||
throw new Error(`Invalid feed URL: ${url}`);
|
throw new Error(`Invalid feed URL: ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for cancellation
|
// Check for cancellation
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Feed processing was cancelled');
|
throw new Error("Feed processing was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔍 Processing feed: ${url}`);
|
console.log(`🔍 Processing feed: ${url}`);
|
||||||
@ -132,10 +141,10 @@ async function processFeedUrl(url: string, abortSignal?: AbortSignal): Promise<v
|
|||||||
// Parse RSS feed
|
// Parse RSS feed
|
||||||
const parser = new Parser<FeedItem>();
|
const parser = new Parser<FeedItem>();
|
||||||
const feed = await parser.parseURL(url);
|
const feed = await parser.parseURL(url);
|
||||||
|
|
||||||
// Check for cancellation after parsing
|
// Check for cancellation after parsing
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Feed processing was cancelled');
|
throw new Error("Feed processing was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create feed record
|
// Get or create feed record
|
||||||
@ -225,13 +234,15 @@ async function discoverNewArticles(
|
|||||||
/**
|
/**
|
||||||
* Process unprocessed articles and generate podcasts
|
* Process unprocessed articles and generate podcasts
|
||||||
*/
|
*/
|
||||||
async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<void> {
|
async function processUnprocessedArticles(
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
console.log("🎧 Processing unprocessed articles...");
|
console.log("🎧 Processing unprocessed articles...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for cancellation
|
// Check for cancellation
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Article processing was cancelled');
|
throw new Error("Article processing was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process retry queue first
|
// Process retry queue first
|
||||||
@ -239,7 +250,7 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
|
|||||||
|
|
||||||
// Check for cancellation after retry queue
|
// Check for cancellation after retry queue
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Article processing was cancelled');
|
throw new Error("Article processing was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get unprocessed articles (limit to prevent overwhelming)
|
// Get unprocessed articles (limit to prevent overwhelming)
|
||||||
@ -260,31 +271,44 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
|
|||||||
for (const article of unprocessedArticles) {
|
for (const article of unprocessedArticles) {
|
||||||
// Check for cancellation before processing each article
|
// Check for cancellation before processing each article
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Article processing was cancelled');
|
throw new Error("Article processing was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const episodeCreated = await generatePodcastForArticle(article, abortSignal);
|
const episodeCreated = await generatePodcastForArticle(
|
||||||
|
article,
|
||||||
|
abortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
// Only mark as processed and update RSS if episode was actually created
|
// Only mark as processed and update RSS if episode was actually created
|
||||||
if (episodeCreated) {
|
if (episodeCreated) {
|
||||||
await markArticleAsProcessed(article.id);
|
await markArticleAsProcessed(article.id);
|
||||||
console.log(`✅ Podcast generated for: ${article.title}`);
|
console.log(`✅ Podcast generated for: ${article.title}`);
|
||||||
successfullyGeneratedCount++;
|
successfullyGeneratedCount++;
|
||||||
|
|
||||||
// Update RSS immediately after each successful episode creation
|
// Update RSS immediately after each successful episode creation
|
||||||
console.log(`📻 Updating podcast RSS after successful episode creation...`);
|
console.log(
|
||||||
|
`📻 Updating podcast RSS after successful episode creation...`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await updatePodcastRSS();
|
await updatePodcastRSS();
|
||||||
console.log(`📻 RSS updated successfully for: ${article.title}`);
|
console.log(`📻 RSS updated successfully for: ${article.title}`);
|
||||||
} catch (rssError) {
|
} catch (rssError) {
|
||||||
console.error(`❌ Failed to update RSS after episode creation for: ${article.title}`, rssError);
|
console.error(
|
||||||
|
`❌ Failed to update RSS after episode creation for: ${article.title}`,
|
||||||
|
rssError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(`⚠️ Episode creation failed for: ${article.title} - not marking as processed`);
|
console.warn(
|
||||||
|
`⚠️ Episode creation failed for: ${article.title} - not marking as processed`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.includes("cancelled") || error.name === "AbortError")
|
||||||
|
) {
|
||||||
console.log(`🛑 Article processing cancelled, stopping batch`);
|
console.log(`🛑 Article processing cancelled, stopping batch`);
|
||||||
throw error; // Re-throw to propagate cancellation
|
throw error; // Re-throw to propagate cancellation
|
||||||
}
|
}
|
||||||
@ -296,7 +320,9 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`);
|
console.log(
|
||||||
|
`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`,
|
||||||
|
);
|
||||||
if (successfullyGeneratedCount === 0) {
|
if (successfullyGeneratedCount === 0) {
|
||||||
console.log(`ℹ️ No episodes were successfully created in this batch`);
|
console.log(`ℹ️ No episodes were successfully created in this batch`);
|
||||||
}
|
}
|
||||||
@ -310,15 +336,16 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
|
|||||||
* Process retry queue for failed TTS generation
|
* Process retry queue for failed TTS generation
|
||||||
*/
|
*/
|
||||||
async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
|
async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
|
||||||
const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js");
|
const { getQueueItems, updateQueueItemStatus, removeFromQueue } =
|
||||||
|
await import("../services/database.js");
|
||||||
const { Database } = await import("bun:sqlite");
|
const { Database } = await import("bun:sqlite");
|
||||||
const db = new Database(config.paths.dbPath);
|
const db = new Database(config.paths.dbPath);
|
||||||
|
|
||||||
console.log("🔄 Processing TTS retry queue...");
|
console.log("🔄 Processing TTS retry queue...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const queueItems = await getQueueItems(5); // Process 5 items at a time
|
const queueItems = await getQueueItems(5); // Process 5 items at a time
|
||||||
|
|
||||||
if (queueItems.length === 0) {
|
if (queueItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -328,53 +355,75 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
|
|||||||
for (const item of queueItems) {
|
for (const item of queueItems) {
|
||||||
// Check for cancellation before processing each retry item
|
// Check for cancellation before processing each retry item
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Retry queue processing was cancelled');
|
throw new Error("Retry queue processing was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`);
|
console.log(
|
||||||
|
`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`,
|
||||||
|
);
|
||||||
|
|
||||||
// Mark as processing
|
// Mark as processing
|
||||||
await updateQueueItemStatus(item.id, 'processing');
|
await updateQueueItemStatus(item.id, "processing");
|
||||||
|
|
||||||
// Attempt TTS generation without re-queuing on failure
|
// Attempt TTS generation without re-queuing on failure
|
||||||
const audioFilePath = await generateTTSWithoutQueue(item.itemId, item.scriptText, item.retryCount);
|
const audioFilePath = await generateTTSWithoutQueue(
|
||||||
|
item.itemId,
|
||||||
|
item.scriptText,
|
||||||
|
item.retryCount,
|
||||||
|
);
|
||||||
|
|
||||||
// Success - remove from queue and update RSS
|
// Success - remove from queue and update RSS
|
||||||
await removeFromQueue(item.id);
|
await removeFromQueue(item.id);
|
||||||
console.log(`✅ TTS retry successful for: ${item.itemId}`);
|
console.log(`✅ TTS retry successful for: ${item.itemId}`);
|
||||||
|
|
||||||
// Update RSS immediately after successful retry
|
// Update RSS immediately after successful retry
|
||||||
console.log(`📻 Updating podcast RSS after successful retry...`);
|
console.log(`📻 Updating podcast RSS after successful retry...`);
|
||||||
try {
|
try {
|
||||||
await updatePodcastRSS();
|
await updatePodcastRSS();
|
||||||
console.log(`📻 RSS updated successfully after retry for: ${item.itemId}`);
|
console.log(
|
||||||
|
`📻 RSS updated successfully after retry for: ${item.itemId}`,
|
||||||
|
);
|
||||||
} catch (rssError) {
|
} catch (rssError) {
|
||||||
console.error(`❌ Failed to update RSS after retry for: ${item.itemId}`, rssError);
|
console.error(
|
||||||
|
`❌ Failed to update RSS after retry for: ${item.itemId}`,
|
||||||
|
rssError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.includes("cancelled") || error.name === "AbortError")
|
||||||
|
) {
|
||||||
console.log(`🛑 TTS retry processing cancelled for: ${item.itemId}`);
|
console.log(`🛑 TTS retry processing cancelled for: ${item.itemId}`);
|
||||||
throw error; // Re-throw cancellation errors
|
throw error; // Re-throw cancellation errors
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
|
console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (item.retryCount >= 2) {
|
if (item.retryCount >= 2) {
|
||||||
// Max retries reached, mark as failed
|
// Max retries reached, mark as failed
|
||||||
await updateQueueItemStatus(item.id, 'failed');
|
await updateQueueItemStatus(item.id, "failed");
|
||||||
console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`);
|
console.log(
|
||||||
|
`💀 Max retries reached for: ${item.itemId}, marking as failed`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Increment retry count and reset to pending for next retry
|
// Increment retry count and reset to pending for next retry
|
||||||
const updatedRetryCount = item.retryCount + 1;
|
const updatedRetryCount = item.retryCount + 1;
|
||||||
const stmt = db.prepare("UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?");
|
const stmt = db.prepare(
|
||||||
|
"UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?",
|
||||||
|
);
|
||||||
stmt.run(updatedRetryCount, item.id);
|
stmt.run(updatedRetryCount, item.id);
|
||||||
console.log(`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`);
|
console.log(
|
||||||
|
`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error(`❌ Failed to update queue status for: ${item.itemId}`, dbError);
|
console.error(
|
||||||
|
`❌ Failed to update queue status for: ${item.itemId}`,
|
||||||
|
dbError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -386,7 +435,10 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
db.close();
|
db.close();
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
console.warn("⚠️ Warning: Failed to close database connection:", closeError);
|
console.warn(
|
||||||
|
"⚠️ Warning: Failed to close database connection:",
|
||||||
|
closeError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -395,13 +447,16 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
|
|||||||
* Generate podcast for a single article
|
* Generate podcast for a single article
|
||||||
* Returns true if episode was successfully created, false otherwise
|
* Returns true if episode was successfully created, false otherwise
|
||||||
*/
|
*/
|
||||||
async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal): Promise<boolean> {
|
async function generatePodcastForArticle(
|
||||||
|
article: any,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<boolean> {
|
||||||
console.log(`🎤 Generating podcast for: ${article.title}`);
|
console.log(`🎤 Generating podcast for: ${article.title}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for cancellation
|
// Check for cancellation
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Podcast generation was cancelled');
|
throw new Error("Podcast generation was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get feed information for context
|
// Get feed information for context
|
||||||
@ -410,7 +465,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
|
|||||||
|
|
||||||
// Check for cancellation before classification
|
// Check for cancellation before classification
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Podcast generation was cancelled');
|
throw new Error("Podcast generation was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify the article/feed
|
// Classify the article/feed
|
||||||
@ -421,7 +476,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
|
|||||||
|
|
||||||
// Check for cancellation before content generation
|
// Check for cancellation before content generation
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Podcast generation was cancelled');
|
throw new Error("Podcast generation was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhance article content with web scraping if needed
|
// Enhance article content with web scraping if needed
|
||||||
@ -430,7 +485,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
|
|||||||
article.title,
|
article.title,
|
||||||
article.link,
|
article.link,
|
||||||
article.content,
|
article.content,
|
||||||
article.description
|
article.description,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate podcast content for this single article
|
// Generate podcast content for this single article
|
||||||
@ -442,10 +497,10 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
|
|||||||
description: enhancedContent.description,
|
description: enhancedContent.description,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check for cancellation before TTS
|
// Check for cancellation before TTS
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error('Podcast generation was cancelled');
|
throw new Error("Podcast generation was cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique ID for the episode
|
// Generate unique ID for the episode
|
||||||
@ -458,15 +513,20 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
|
|||||||
console.log(`🔊 Audio generated: ${audioFilePath}`);
|
console.log(`🔊 Audio generated: ${audioFilePath}`);
|
||||||
} catch (ttsError) {
|
} catch (ttsError) {
|
||||||
console.error(`❌ TTS generation failed for ${article.title}:`, ttsError);
|
console.error(`❌ TTS generation failed for ${article.title}:`, ttsError);
|
||||||
|
|
||||||
// Check if error indicates item was added to retry queue
|
// Check if error indicates item was added to retry queue
|
||||||
const errorMessage = ttsError instanceof Error ? ttsError.message : String(ttsError);
|
const errorMessage =
|
||||||
if (errorMessage.includes('added to retry queue')) {
|
ttsError instanceof Error ? ttsError.message : String(ttsError);
|
||||||
console.log(`📋 Article will be retried later via TTS queue: ${article.title}`);
|
if (errorMessage.includes("added to retry queue")) {
|
||||||
|
console.log(
|
||||||
|
`📋 Article will be retried later via TTS queue: ${article.title}`,
|
||||||
|
);
|
||||||
// Don't mark as processed - leave it for retry
|
// Don't mark as processed - leave it for retry
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
console.error(`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`);
|
console.error(
|
||||||
|
`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`,
|
||||||
|
);
|
||||||
// Max retries exceeded, don't create episode but mark as processed to avoid infinite retry
|
// Max retries exceeded, don't create episode but mark as processed to avoid infinite retry
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -476,11 +536,16 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
|
|||||||
try {
|
try {
|
||||||
const audioStats = await getAudioFileStats(audioFilePath);
|
const audioStats = await getAudioFileStats(audioFilePath);
|
||||||
if (audioStats.size === 0) {
|
if (audioStats.size === 0) {
|
||||||
console.error(`❌ Audio file is empty for ${article.title}: ${audioFilePath}`);
|
console.error(
|
||||||
|
`❌ Audio file is empty for ${article.title}: ${audioFilePath}`,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (statsError) {
|
} catch (statsError) {
|
||||||
console.error(`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`, statsError);
|
console.error(
|
||||||
|
`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`,
|
||||||
|
statsError,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -502,11 +567,17 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
|
|||||||
console.log(`💾 Episode saved for article: ${article.title}`);
|
console.log(`💾 Episode saved for article: ${article.title}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
console.error(`❌ Failed to save episode for ${article.title}:`, saveError);
|
console.error(
|
||||||
|
`❌ Failed to save episode for ${article.title}:`,
|
||||||
|
saveError,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.includes("cancelled") || error.name === "AbortError")
|
||||||
|
) {
|
||||||
console.log(`🛑 Podcast generation cancelled for: ${article.title}`);
|
console.log(`🛑 Podcast generation cancelled for: ${article.title}`);
|
||||||
throw error; // Re-throw cancellation errors to stop the batch
|
throw error; // Re-throw cancellation errors to stop the batch
|
||||||
}
|
}
|
||||||
|
133
server.ts
133
server.ts
@ -22,7 +22,7 @@ app.get("/assets/*", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const filePath = path.join(config.paths.frontendBuildDir, c.req.path);
|
const filePath = path.join(config.paths.frontendBuildDir, c.req.path);
|
||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const contentType = filePath.endsWith(".js")
|
const contentType = filePath.endsWith(".js")
|
||||||
? "application/javascript"
|
? "application/javascript"
|
||||||
@ -42,15 +42,18 @@ app.get("/assets/*", async (c) => {
|
|||||||
app.get("/podcast_audio/*", async (c) => {
|
app.get("/podcast_audio/*", async (c) => {
|
||||||
try {
|
try {
|
||||||
const audioFileName = c.req.path.substring("/podcast_audio/".length);
|
const audioFileName = c.req.path.substring("/podcast_audio/".length);
|
||||||
|
|
||||||
// Basic security check
|
// Basic security check
|
||||||
if (audioFileName.includes("..") || audioFileName.includes("/")) {
|
if (audioFileName.includes("..") || audioFileName.includes("/")) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioFilePath = path.join(config.paths.podcastAudioDir, audioFileName);
|
const audioFilePath = path.join(
|
||||||
|
config.paths.podcastAudioDir,
|
||||||
|
audioFileName,
|
||||||
|
);
|
||||||
const file = Bun.file(audioFilePath);
|
const file = Bun.file(audioFilePath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const blob = await file.arrayBuffer();
|
const blob = await file.arrayBuffer();
|
||||||
return c.body(blob, 200, { "Content-Type": "audio/mpeg" });
|
return c.body(blob, 200, { "Content-Type": "audio/mpeg" });
|
||||||
@ -66,7 +69,7 @@ app.get("/podcast.xml", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const filePath = path.join(config.paths.publicDir, "podcast.xml");
|
const filePath = path.join(config.paths.publicDir, "podcast.xml");
|
||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const blob = await file.arrayBuffer();
|
const blob = await file.arrayBuffer();
|
||||||
return c.body(blob, 200, {
|
return c.body(blob, 200, {
|
||||||
@ -74,7 +77,7 @@ app.get("/podcast.xml", async (c) => {
|
|||||||
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn("podcast.xml not found");
|
console.warn("podcast.xml not found");
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -83,18 +86,17 @@ app.get("/podcast.xml", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Frontend fallback routes
|
// Frontend fallback routes
|
||||||
async function serveIndex(c: any) {
|
async function serveIndex(c: any) {
|
||||||
try {
|
try {
|
||||||
const indexPath = path.join(config.paths.frontendBuildDir, "index.html");
|
const indexPath = path.join(config.paths.frontendBuildDir, "index.html");
|
||||||
const file = Bun.file(indexPath);
|
const file = Bun.file(indexPath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const blob = await file.arrayBuffer();
|
const blob = await file.arrayBuffer();
|
||||||
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
|
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`index.html not found at ${indexPath}`);
|
console.error(`index.html not found at ${indexPath}`);
|
||||||
return c.text("Frontend not built. Run 'bun run build:frontend'", 404);
|
return c.text("Frontend not built. Run 'bun run build:frontend'", 404);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -110,7 +112,9 @@ app.get("/index.html", serveIndex);
|
|||||||
// API endpoints for frontend
|
// API endpoints for frontend
|
||||||
app.get("/api/episodes", async (c) => {
|
app.get("/api/episodes", async (c) => {
|
||||||
try {
|
try {
|
||||||
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js");
|
const { fetchEpisodesWithFeedInfo } = await import(
|
||||||
|
"./services/database.js"
|
||||||
|
);
|
||||||
const episodes = await fetchEpisodesWithFeedInfo();
|
const episodes = await fetchEpisodesWithFeedInfo();
|
||||||
return c.json({ episodes });
|
return c.json({ episodes });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -124,34 +128,34 @@ app.get("/api/episodes-from-xml", async (c) => {
|
|||||||
const xml2js = await import("xml2js");
|
const xml2js = await import("xml2js");
|
||||||
const fs = await import("fs");
|
const fs = await import("fs");
|
||||||
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
|
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
|
||||||
|
|
||||||
// Check if podcast.xml exists
|
// Check if podcast.xml exists
|
||||||
if (!fs.existsSync(podcastXmlPath)) {
|
if (!fs.existsSync(podcastXmlPath)) {
|
||||||
return c.json({ episodes: [], message: "podcast.xml not found" });
|
return c.json({ episodes: [], message: "podcast.xml not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse XML
|
// Read and parse XML
|
||||||
const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8');
|
const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
|
||||||
const parser = new xml2js.Parser();
|
const parser = new xml2js.Parser();
|
||||||
const result = await parser.parseStringPromise(xmlContent);
|
const result = await parser.parseStringPromise(xmlContent);
|
||||||
|
|
||||||
const episodes = [];
|
const episodes = [];
|
||||||
const items = result?.rss?.channel?.[0]?.item || [];
|
const items = result?.rss?.channel?.[0]?.item || [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const episode = {
|
const episode = {
|
||||||
id: generateEpisodeId(item),
|
id: generateEpisodeId(item),
|
||||||
title: item.title?.[0] || 'Untitled',
|
title: item.title?.[0] || "Untitled",
|
||||||
description: item.description?.[0] || '',
|
description: item.description?.[0] || "",
|
||||||
pubDate: item.pubDate?.[0] || '',
|
pubDate: item.pubDate?.[0] || "",
|
||||||
audioUrl: item.enclosure?.[0]?.$?.url || '',
|
audioUrl: item.enclosure?.[0]?.$?.url || "",
|
||||||
audioLength: item.enclosure?.[0]?.$?.length || '0',
|
audioLength: item.enclosure?.[0]?.$?.length || "0",
|
||||||
guid: item.guid?.[0] || '',
|
guid: item.guid?.[0] || "",
|
||||||
link: item.link?.[0] || ''
|
link: item.link?.[0] || "",
|
||||||
};
|
};
|
||||||
episodes.push(episode);
|
episodes.push(episode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ episodes });
|
return c.json({ episodes });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing podcast XML:", error);
|
console.error("Error parsing podcast XML:", error);
|
||||||
@ -163,19 +167,20 @@ app.get("/api/episodes-from-xml", async (c) => {
|
|||||||
function generateEpisodeId(item: any): string {
|
function generateEpisodeId(item: any): string {
|
||||||
// Use GUID if available, otherwise generate from title and audio URL
|
// Use GUID if available, otherwise generate from title and audio URL
|
||||||
if (item.guid?.[0]) {
|
if (item.guid?.[0]) {
|
||||||
return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, '-'));
|
return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, "-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = item.title?.[0] || '';
|
const title = item.title?.[0] || "";
|
||||||
const audioUrl = item.enclosure?.[0]?.$?.url || '';
|
const audioUrl = item.enclosure?.[0]?.$?.url || "";
|
||||||
const titleSlug = title.toLowerCase()
|
const titleSlug = title
|
||||||
.replace(/[^a-zA-Z0-9\s]/g, '')
|
.toLowerCase()
|
||||||
.replace(/\s+/g, '-')
|
.replace(/[^a-zA-Z0-9\s]/g, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
.substring(0, 50);
|
.substring(0, 50);
|
||||||
|
|
||||||
// Extract filename from audio URL as fallback
|
// Extract filename from audio URL as fallback
|
||||||
const audioFilename = audioUrl.split('/').pop()?.split('.')[0] || 'episode';
|
const audioFilename = audioUrl.split("/").pop()?.split(".")[0] || "episode";
|
||||||
|
|
||||||
return titleSlug || audioFilename;
|
return titleSlug || audioFilename;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,33 +190,35 @@ app.get("/api/episode/:episodeId", async (c) => {
|
|||||||
const xml2js = await import("xml2js");
|
const xml2js = await import("xml2js");
|
||||||
const fs = await import("fs");
|
const fs = await import("fs");
|
||||||
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
|
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
|
||||||
|
|
||||||
if (!fs.existsSync(podcastXmlPath)) {
|
if (!fs.existsSync(podcastXmlPath)) {
|
||||||
return c.json({ error: "podcast.xml not found" }, 404);
|
return c.json({ error: "podcast.xml not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8');
|
const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
|
||||||
const parser = new xml2js.Parser();
|
const parser = new xml2js.Parser();
|
||||||
const result = await parser.parseStringPromise(xmlContent);
|
const result = await parser.parseStringPromise(xmlContent);
|
||||||
|
|
||||||
const items = result?.rss?.channel?.[0]?.item || [];
|
const items = result?.rss?.channel?.[0]?.item || [];
|
||||||
const targetItem = items.find((item: any) => generateEpisodeId(item) === episodeId);
|
const targetItem = items.find(
|
||||||
|
(item: any) => generateEpisodeId(item) === episodeId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!targetItem) {
|
if (!targetItem) {
|
||||||
return c.json({ error: "Episode not found" }, 404);
|
return c.json({ error: "Episode not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const episode = {
|
const episode = {
|
||||||
id: episodeId,
|
id: episodeId,
|
||||||
title: targetItem.title?.[0] || 'Untitled',
|
title: targetItem.title?.[0] || "Untitled",
|
||||||
description: targetItem.description?.[0] || '',
|
description: targetItem.description?.[0] || "",
|
||||||
pubDate: targetItem.pubDate?.[0] || '',
|
pubDate: targetItem.pubDate?.[0] || "",
|
||||||
audioUrl: targetItem.enclosure?.[0]?.$?.url || '',
|
audioUrl: targetItem.enclosure?.[0]?.$?.url || "",
|
||||||
audioLength: targetItem.enclosure?.[0]?.$?.length || '0',
|
audioLength: targetItem.enclosure?.[0]?.$?.length || "0",
|
||||||
guid: targetItem.guid?.[0] || '',
|
guid: targetItem.guid?.[0] || "",
|
||||||
link: targetItem.link?.[0] || ''
|
link: targetItem.link?.[0] || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json({ episode });
|
return c.json({ episode });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching episode:", error);
|
console.error("Error fetching episode:", error);
|
||||||
@ -235,11 +242,11 @@ app.get("/api/feeds/:feedId", async (c) => {
|
|||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
const { getFeedById } = await import("./services/database.js");
|
const { getFeedById } = await import("./services/database.js");
|
||||||
const feed = await getFeedById(feedId);
|
const feed = await getFeedById(feedId);
|
||||||
|
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
return c.json({ error: "Feed not found" }, 404);
|
return c.json({ error: "Feed not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ feed });
|
return c.json({ feed });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching feed:", error);
|
console.error("Error fetching feed:", error);
|
||||||
@ -261,7 +268,9 @@ app.get("/api/feeds/:feedId/episodes", async (c) => {
|
|||||||
|
|
||||||
app.get("/api/episodes-with-feed-info", async (c) => {
|
app.get("/api/episodes-with-feed-info", async (c) => {
|
||||||
try {
|
try {
|
||||||
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js");
|
const { fetchEpisodesWithFeedInfo } = await import(
|
||||||
|
"./services/database.js"
|
||||||
|
);
|
||||||
const episodes = await fetchEpisodesWithFeedInfo();
|
const episodes = await fetchEpisodesWithFeedInfo();
|
||||||
return c.json({ episodes });
|
return c.json({ episodes });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -273,13 +282,15 @@ app.get("/api/episodes-with-feed-info", async (c) => {
|
|||||||
app.get("/api/episode-with-source/:episodeId", async (c) => {
|
app.get("/api/episode-with-source/:episodeId", async (c) => {
|
||||||
try {
|
try {
|
||||||
const episodeId = c.req.param("episodeId");
|
const episodeId = c.req.param("episodeId");
|
||||||
const { fetchEpisodeWithSourceInfo } = await import("./services/database.js");
|
const { fetchEpisodeWithSourceInfo } = await import(
|
||||||
|
"./services/database.js"
|
||||||
|
);
|
||||||
const episode = await fetchEpisodeWithSourceInfo(episodeId);
|
const episode = await fetchEpisodeWithSourceInfo(episodeId);
|
||||||
|
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
return c.json({ error: "Episode not found" }, 404);
|
return c.json({ error: "Episode not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ episode });
|
return c.json({ episode });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching episode with source info:", error);
|
console.error("Error fetching episode with source info:", error);
|
||||||
@ -291,8 +302,8 @@ app.post("/api/feed-requests", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { url, requestMessage } = body;
|
const { url, requestMessage } = body;
|
||||||
|
|
||||||
if (!url || typeof url !== 'string') {
|
if (!url || typeof url !== "string") {
|
||||||
return c.json({ error: "URL is required" }, 400);
|
return c.json({ error: "URL is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,11 +312,11 @@ app.post("/api/feed-requests", async (c) => {
|
|||||||
url,
|
url,
|
||||||
requestMessage,
|
requestMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Feed request submitted successfully",
|
message: "Feed request submitted successfully",
|
||||||
requestId
|
requestId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting feed request:", error);
|
console.error("Error submitting feed request:", error);
|
||||||
@ -329,6 +340,8 @@ serve(
|
|||||||
console.log(`🌟 Server is running on http://localhost:${info.port}`);
|
console.log(`🌟 Server is running on http://localhost:${info.port}`);
|
||||||
console.log(`📡 Using configuration from: ${config.paths.projectRoot}`);
|
console.log(`📡 Using configuration from: ${config.paths.projectRoot}`);
|
||||||
console.log(`🗄️ Database: ${config.paths.dbPath}`);
|
console.log(`🗄️ Database: ${config.paths.dbPath}`);
|
||||||
console.log(`🔄 Batch scheduler is active and will manage automatic processing`);
|
console.log(
|
||||||
|
`🔄 Batch scheduler is active and will manage automatic processing`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -16,7 +16,7 @@ class BatchScheduler {
|
|||||||
isRunning: false,
|
isRunning: false,
|
||||||
canForceStop: false,
|
canForceStop: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
private currentAbortController?: AbortController;
|
private currentAbortController?: AbortController;
|
||||||
|
|
||||||
private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
|
private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
|
||||||
@ -58,7 +58,7 @@ class BatchScheduler {
|
|||||||
this.state.nextRun = new Date(nextRunTime).toISOString();
|
this.state.nextRun = new Date(nextRunTime).toISOString();
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`🕕 Next batch process scheduled for: ${new Date(nextRunTime).toLocaleString()}`
|
`🕕 Next batch process scheduled for: ${new Date(nextRunTime).toLocaleString()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.state.intervalId = setTimeout(async () => {
|
this.state.intervalId = setTimeout(async () => {
|
||||||
@ -78,7 +78,7 @@ class BatchScheduler {
|
|||||||
this.state.isRunning = true;
|
this.state.isRunning = true;
|
||||||
this.state.canForceStop = true;
|
this.state.canForceStop = true;
|
||||||
this.state.lastRun = new Date().toISOString();
|
this.state.lastRun = new Date().toISOString();
|
||||||
|
|
||||||
// Create new AbortController for this batch run
|
// Create new AbortController for this batch run
|
||||||
this.currentAbortController = new AbortController();
|
this.currentAbortController = new AbortController();
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ class BatchScheduler {
|
|||||||
await batchProcess(this.currentAbortController.signal);
|
await batchProcess(this.currentAbortController.signal);
|
||||||
console.log("✅ Scheduled batch process completed");
|
console.log("✅ Scheduled batch process completed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
console.log("🛑 Batch process was forcefully stopped");
|
console.log("🛑 Batch process was forcefully stopped");
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ Error during scheduled batch process:", error);
|
console.error("❌ Error during scheduled batch process:", error);
|
||||||
@ -162,4 +162,4 @@ class BatchScheduler {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const batchScheduler = new BatchScheduler();
|
export const batchScheduler = new BatchScheduler();
|
||||||
|
|
||||||
export type { BatchSchedulerState };
|
export type { BatchSchedulerState };
|
||||||
|
@ -7,13 +7,13 @@ interface Config {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// VOICEVOX Configuration
|
// VOICEVOX Configuration
|
||||||
voicevox: {
|
voicevox: {
|
||||||
host: string;
|
host: string;
|
||||||
styleId: number;
|
styleId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Podcast Configuration
|
// Podcast Configuration
|
||||||
podcast: {
|
podcast: {
|
||||||
title: string;
|
title: string;
|
||||||
@ -25,19 +25,19 @@ interface Config {
|
|||||||
ttl: string;
|
ttl: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin Panel Configuration
|
// Admin Panel Configuration
|
||||||
admin: {
|
admin: {
|
||||||
port: number;
|
port: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Batch Processing Configuration
|
// Batch Processing Configuration
|
||||||
batch: {
|
batch: {
|
||||||
disableInitialRun: boolean;
|
disableInitialRun: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// File paths
|
// File paths
|
||||||
paths: {
|
paths: {
|
||||||
projectRoot: string;
|
projectRoot: string;
|
||||||
@ -64,43 +64,52 @@ function getOptionalEnv(key: string, defaultValue: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createConfig(): Config {
|
function createConfig(): Config {
|
||||||
const projectRoot = import.meta.dirname ? path.dirname(import.meta.dirname) : process.cwd();
|
const projectRoot = import.meta.dirname
|
||||||
|
? path.dirname(import.meta.dirname)
|
||||||
|
: process.cwd();
|
||||||
const dataDir = path.join(projectRoot, "data");
|
const dataDir = path.join(projectRoot, "data");
|
||||||
const publicDir = path.join(projectRoot, "public");
|
const publicDir = path.join(projectRoot, "public");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openai: {
|
openai: {
|
||||||
apiKey: getRequiredEnv("OPENAI_API_KEY"),
|
apiKey: getRequiredEnv("OPENAI_API_KEY"),
|
||||||
endpoint: getOptionalEnv("OPENAI_API_ENDPOINT", "https://api.openai.com/v1"),
|
endpoint: getOptionalEnv(
|
||||||
|
"OPENAI_API_ENDPOINT",
|
||||||
|
"https://api.openai.com/v1",
|
||||||
|
),
|
||||||
modelName: getOptionalEnv("OPENAI_MODEL_NAME", "gpt-4o-mini"),
|
modelName: getOptionalEnv("OPENAI_MODEL_NAME", "gpt-4o-mini"),
|
||||||
},
|
},
|
||||||
|
|
||||||
voicevox: {
|
voicevox: {
|
||||||
host: getOptionalEnv("VOICEVOX_HOST", "http://localhost:50021"),
|
host: getOptionalEnv("VOICEVOX_HOST", "http://localhost:50021"),
|
||||||
styleId: parseInt(getOptionalEnv("VOICEVOX_STYLE_ID", "0")),
|
styleId: parseInt(getOptionalEnv("VOICEVOX_STYLE_ID", "0")),
|
||||||
},
|
},
|
||||||
|
|
||||||
podcast: {
|
podcast: {
|
||||||
title: getOptionalEnv("PODCAST_TITLE", "自動生成ポッドキャスト"),
|
title: getOptionalEnv("PODCAST_TITLE", "自動生成ポッドキャスト"),
|
||||||
link: getOptionalEnv("PODCAST_LINK", "https://your-domain.com/podcast"),
|
link: getOptionalEnv("PODCAST_LINK", "https://your-domain.com/podcast"),
|
||||||
description: getOptionalEnv("PODCAST_DESCRIPTION", "RSSフィードから自動生成された音声ポッドキャスト"),
|
description: getOptionalEnv(
|
||||||
|
"PODCAST_DESCRIPTION",
|
||||||
|
"RSSフィードから自動生成された音声ポッドキャスト",
|
||||||
|
),
|
||||||
language: getOptionalEnv("PODCAST_LANGUAGE", "ja"),
|
language: getOptionalEnv("PODCAST_LANGUAGE", "ja"),
|
||||||
author: getOptionalEnv("PODCAST_AUTHOR", "管理者"),
|
author: getOptionalEnv("PODCAST_AUTHOR", "管理者"),
|
||||||
categories: getOptionalEnv("PODCAST_CATEGORIES", "Technology"),
|
categories: getOptionalEnv("PODCAST_CATEGORIES", "Technology"),
|
||||||
ttl: getOptionalEnv("PODCAST_TTL", "60"),
|
ttl: getOptionalEnv("PODCAST_TTL", "60"),
|
||||||
baseUrl: getOptionalEnv("PODCAST_BASE_URL", "https://your-domain.com"),
|
baseUrl: getOptionalEnv("PODCAST_BASE_URL", "https://your-domain.com"),
|
||||||
},
|
},
|
||||||
|
|
||||||
admin: {
|
admin: {
|
||||||
port: parseInt(getOptionalEnv("ADMIN_PORT", "3001")),
|
port: parseInt(getOptionalEnv("ADMIN_PORT", "3001")),
|
||||||
username: import.meta.env["ADMIN_USERNAME"],
|
username: import.meta.env["ADMIN_USERNAME"],
|
||||||
password: import.meta.env["ADMIN_PASSWORD"],
|
password: import.meta.env["ADMIN_PASSWORD"],
|
||||||
},
|
},
|
||||||
|
|
||||||
batch: {
|
batch: {
|
||||||
disableInitialRun: getOptionalEnv("DISABLE_INITIAL_BATCH", "false") === "true",
|
disableInitialRun:
|
||||||
|
getOptionalEnv("DISABLE_INITIAL_BATCH", "false") === "true",
|
||||||
},
|
},
|
||||||
|
|
||||||
paths: {
|
paths: {
|
||||||
projectRoot,
|
projectRoot,
|
||||||
dataDir,
|
dataDir,
|
||||||
@ -109,7 +118,10 @@ function createConfig(): Config {
|
|||||||
podcastAudioDir: path.join(publicDir, "podcast_audio"),
|
podcastAudioDir: path.join(publicDir, "podcast_audio"),
|
||||||
frontendBuildDir: path.join(projectRoot, "frontend", "dist"),
|
frontendBuildDir: path.join(projectRoot, "frontend", "dist"),
|
||||||
adminBuildDir: path.join(projectRoot, "admin-panel", "dist"),
|
adminBuildDir: path.join(projectRoot, "admin-panel", "dist"),
|
||||||
feedUrlsFile: path.join(projectRoot, getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt")),
|
feedUrlsFile: path.join(
|
||||||
|
projectRoot,
|
||||||
|
getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -121,21 +133,21 @@ export function validateConfig(): void {
|
|||||||
if (!config.openai.apiKey) {
|
if (!config.openai.apiKey) {
|
||||||
throw new Error("OPENAI_API_KEY is required");
|
throw new Error("OPENAI_API_KEY is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(config.voicevox.styleId)) {
|
if (isNaN(config.voicevox.styleId)) {
|
||||||
throw new Error("VOICEVOX_STYLE_ID must be a valid number");
|
throw new Error("VOICEVOX_STYLE_ID must be a valid number");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate URLs
|
// Validate URLs
|
||||||
try {
|
try {
|
||||||
new URL(config.voicevox.host);
|
new URL(config.voicevox.host);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("VOICEVOX_HOST must be a valid URL");
|
throw new Error("VOICEVOX_HOST must be a valid URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(config.openai.endpoint);
|
new URL(config.openai.endpoint);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("OPENAI_API_ENDPOINT must be a valid URL");
|
throw new Error("OPENAI_API_ENDPOINT must be a valid URL");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from "cheerio";
|
||||||
|
|
||||||
export interface ExtractedContent {
|
export interface ExtractedContent {
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -8,17 +8,21 @@ export interface ExtractedContent {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractArticleContent(url: string): Promise<ExtractedContent> {
|
export async function extractArticleContent(
|
||||||
|
url: string,
|
||||||
|
): Promise<ExtractedContent> {
|
||||||
try {
|
try {
|
||||||
// Fetch the HTML content
|
// Fetch the HTML content
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
"User-Agent":
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||||
'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
|
Accept:
|
||||||
'Accept-Encoding': 'gzip, deflate',
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||||
'Connection': 'keep-alive',
|
"Accept-Language": "ja,en-US;q=0.7,en;q=0.3",
|
||||||
'Upgrade-Insecure-Requests': '1',
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||||
});
|
});
|
||||||
@ -31,52 +35,56 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
|
|||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
// Remove unwanted elements
|
// Remove unwanted elements
|
||||||
$('script, style, nav, header, footer, aside, .advertisement, .ads, .sidebar, .menu, .navigation, .social-share, .comments').remove();
|
$(
|
||||||
|
"script, style, nav, header, footer, aside, .advertisement, .ads, .sidebar, .menu, .navigation, .social-share, .comments",
|
||||||
|
).remove();
|
||||||
|
|
||||||
let content = '';
|
let content = "";
|
||||||
let title = '';
|
let title = "";
|
||||||
let description = '';
|
let description = "";
|
||||||
|
|
||||||
// Extract title
|
// Extract title
|
||||||
title = $('title').text().trim() ||
|
title =
|
||||||
$('h1').first().text().trim() ||
|
$("title").text().trim() ||
|
||||||
$('meta[property="og:title"]').attr('content') ||
|
$("h1").first().text().trim() ||
|
||||||
'';
|
$('meta[property="og:title"]').attr("content") ||
|
||||||
|
"";
|
||||||
|
|
||||||
// Extract description
|
// Extract description
|
||||||
description = $('meta[name="description"]').attr('content') ||
|
description =
|
||||||
$('meta[property="og:description"]').attr('content') ||
|
$('meta[name="description"]').attr("content") ||
|
||||||
'';
|
$('meta[property="og:description"]').attr("content") ||
|
||||||
|
"";
|
||||||
|
|
||||||
// Try multiple content extraction strategies
|
// Try multiple content extraction strategies
|
||||||
const contentSelectors = [
|
const contentSelectors = [
|
||||||
// Common article selectors
|
// Common article selectors
|
||||||
'article',
|
"article",
|
||||||
'[role="main"]',
|
'[role="main"]',
|
||||||
'.article-content',
|
".article-content",
|
||||||
'.post-content',
|
".post-content",
|
||||||
'.entry-content',
|
".entry-content",
|
||||||
'.content',
|
".content",
|
||||||
'.main-content',
|
".main-content",
|
||||||
'.article-body',
|
".article-body",
|
||||||
'.post-body',
|
".post-body",
|
||||||
'.story-body',
|
".story-body",
|
||||||
'.news-content',
|
".news-content",
|
||||||
|
|
||||||
// Japanese news site specific selectors
|
// Japanese news site specific selectors
|
||||||
'.article',
|
".article",
|
||||||
'.news-article',
|
".news-article",
|
||||||
'.post',
|
".post",
|
||||||
'.entry',
|
".entry",
|
||||||
'#content',
|
"#content",
|
||||||
'#main',
|
"#main",
|
||||||
'.main',
|
".main",
|
||||||
|
|
||||||
// Fallback to common containers
|
// Fallback to common containers
|
||||||
'.container',
|
".container",
|
||||||
'#container',
|
"#container",
|
||||||
'main',
|
"main",
|
||||||
'body'
|
"body",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of contentSelectors) {
|
for (const selector of contentSelectors) {
|
||||||
@ -84,11 +92,11 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
|
|||||||
if (element.length > 0) {
|
if (element.length > 0) {
|
||||||
// Get text content and clean it up
|
// Get text content and clean it up
|
||||||
let extractedText = element.text().trim();
|
let extractedText = element.text().trim();
|
||||||
|
|
||||||
// Remove extra whitespace and normalize
|
// Remove extra whitespace and normalize
|
||||||
extractedText = extractedText
|
extractedText = extractedText
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, " ")
|
||||||
.replace(/\n\s*\n/g, '\n')
|
.replace(/\n\s*\n/g, "\n")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Only use if we found substantial content
|
// Only use if we found substantial content
|
||||||
@ -101,50 +109,49 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
|
|||||||
|
|
||||||
// If still no content, try paragraph extraction
|
// If still no content, try paragraph extraction
|
||||||
if (!content) {
|
if (!content) {
|
||||||
const paragraphs = $('p').map((_, el) => $(el).text().trim()).get();
|
const paragraphs = $("p")
|
||||||
|
.map((_, el) => $(el).text().trim())
|
||||||
|
.get();
|
||||||
content = paragraphs
|
content = paragraphs
|
||||||
.filter(p => p.length > 50) // Filter out short paragraphs
|
.filter((p) => p.length > 50) // Filter out short paragraphs
|
||||||
.join('\n\n');
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final fallback: use body text
|
// Final fallback: use body text
|
||||||
if (!content || content.length < 100) {
|
if (!content || content.length < 100) {
|
||||||
content = $('body').text()
|
content = $("body").text().replace(/\s+/g, " ").trim();
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate extracted content
|
// Validate extracted content
|
||||||
if (!content || content.length < 50) {
|
if (!content || content.length < 50) {
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
content: '',
|
content: "",
|
||||||
description,
|
description,
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Insufficient content extracted'
|
error: "Insufficient content extracted",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit content length to avoid token limits
|
// Limit content length to avoid token limits
|
||||||
const maxLength = 5000;
|
const maxLength = 5000;
|
||||||
if (content.length > maxLength) {
|
if (content.length > maxLength) {
|
||||||
content = content.substring(0, maxLength) + '...';
|
content = content.substring(0, maxLength) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
description,
|
description,
|
||||||
success: true
|
success: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: "",
|
||||||
content: '',
|
content: "",
|
||||||
description: '',
|
description: "",
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -153,30 +160,30 @@ export async function enhanceArticleContent(
|
|||||||
originalTitle: string,
|
originalTitle: string,
|
||||||
originalLink: string,
|
originalLink: string,
|
||||||
originalContent?: string,
|
originalContent?: string,
|
||||||
originalDescription?: string
|
originalDescription?: string,
|
||||||
): Promise<{ content?: string; description?: string }> {
|
): Promise<{ content?: string; description?: string }> {
|
||||||
// If we already have substantial content, use it
|
// If we already have substantial content, use it
|
||||||
const existingContent = originalContent || originalDescription || '';
|
const existingContent = originalContent || originalDescription || "";
|
||||||
if (existingContent.length > 500) {
|
if (existingContent.length > 500) {
|
||||||
return {
|
return {
|
||||||
content: originalContent,
|
content: originalContent,
|
||||||
description: originalDescription
|
description: originalDescription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract content from the URL
|
// Try to extract content from the URL
|
||||||
const extracted = await extractArticleContent(originalLink);
|
const extracted = await extractArticleContent(originalLink);
|
||||||
|
|
||||||
if (extracted.success && extracted.content) {
|
if (extracted.success && extracted.content) {
|
||||||
return {
|
return {
|
||||||
content: extracted.content,
|
content: extracted.content,
|
||||||
description: extracted.description || originalDescription
|
description: extracted.description || originalDescription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return original content if extraction failed
|
// Return original content if extraction failed
|
||||||
return {
|
return {
|
||||||
content: originalContent,
|
content: originalContent,
|
||||||
description: originalDescription
|
description: originalDescription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -59,7 +59,12 @@ export async function openAI_ClassifyFeed(title: string): Promise<string> {
|
|||||||
|
|
||||||
export async function openAI_GeneratePodcastContent(
|
export async function openAI_GeneratePodcastContent(
|
||||||
title: string,
|
title: string,
|
||||||
items: Array<{ title: string; link: string; content?: string; description?: string }>,
|
items: Array<{
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
content?: string;
|
||||||
|
description?: string;
|
||||||
|
}>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!title || title.trim() === "") {
|
if (!title || title.trim() === "") {
|
||||||
throw new Error("Feed title is required for podcast content generation");
|
throw new Error("Feed title is required for podcast content generation");
|
||||||
@ -78,22 +83,25 @@ export async function openAI_GeneratePodcastContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build detailed article information including content
|
// Build detailed article information including content
|
||||||
const articleDetails = validItems.map((item, i) => {
|
const articleDetails = validItems
|
||||||
let articleInfo = `${i + 1}. タイトル: ${item.title}\nURL: ${item.link}`;
|
.map((item, i) => {
|
||||||
|
let articleInfo = `${i + 1}. タイトル: ${item.title}\nURL: ${item.link}`;
|
||||||
// Add content if available
|
|
||||||
const content = item.content || item.description;
|
// Add content if available
|
||||||
if (content && content.trim()) {
|
const content = item.content || item.description;
|
||||||
// Limit content length to avoid token limits
|
if (content && content.trim()) {
|
||||||
const maxContentLength = 2000;
|
// Limit content length to avoid token limits
|
||||||
const truncatedContent = content.length > maxContentLength
|
const maxContentLength = 2000;
|
||||||
? content.substring(0, maxContentLength) + "..."
|
const truncatedContent =
|
||||||
: content;
|
content.length > maxContentLength
|
||||||
articleInfo += `\n内容: ${truncatedContent}`;
|
? content.substring(0, maxContentLength) + "..."
|
||||||
}
|
: content;
|
||||||
|
articleInfo += `\n内容: ${truncatedContent}`;
|
||||||
return articleInfo;
|
}
|
||||||
}).join("\n\n");
|
|
||||||
|
return articleInfo;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
const prompt = `
|
const prompt = `
|
||||||
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。
|
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。
|
||||||
|
111
services/tts.ts
111
services/tts.ts
@ -17,18 +17,18 @@ function splitTextIntoChunks(text: string, maxLength: number = 50): string[] {
|
|||||||
|
|
||||||
// Split by sentences first (Japanese periods and line breaks)
|
// Split by sentences first (Japanese periods and line breaks)
|
||||||
const sentences = text.split(/([。!?\n])/);
|
const sentences = text.split(/([。!?\n])/);
|
||||||
|
|
||||||
for (let i = 0; i < sentences.length; i++) {
|
for (let i = 0; i < sentences.length; i++) {
|
||||||
const sentence = sentences[i];
|
const sentence = sentences[i];
|
||||||
if (!sentence) continue;
|
if (!sentence) continue;
|
||||||
|
|
||||||
if (currentChunk.length + sentence.length <= maxLength) {
|
if (currentChunk.length + sentence.length <= maxLength) {
|
||||||
currentChunk += sentence;
|
currentChunk += sentence;
|
||||||
} else {
|
} else {
|
||||||
if (currentChunk.trim()) {
|
if (currentChunk.trim()) {
|
||||||
chunks.push(currentChunk.trim());
|
chunks.push(currentChunk.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If single sentence is too long, split further
|
// If single sentence is too long, split further
|
||||||
if (sentence.length > maxLength) {
|
if (sentence.length > maxLength) {
|
||||||
const subChunks = splitLongSentence(sentence, maxLength);
|
const subChunks = splitLongSentence(sentence, maxLength);
|
||||||
@ -39,12 +39,12 @@ function splitTextIntoChunks(text: string, maxLength: number = 50): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentChunk.trim()) {
|
if (currentChunk.trim()) {
|
||||||
chunks.push(currentChunk.trim());
|
chunks.push(currentChunk.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks.filter(chunk => chunk.length > 0);
|
return chunks.filter((chunk) => chunk.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,10 +57,10 @@ function splitLongSentence(sentence: string, maxLength: number): string[] {
|
|||||||
|
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
let currentChunk = "";
|
let currentChunk = "";
|
||||||
|
|
||||||
// Split by commas and common Japanese particles
|
// Split by commas and common Japanese particles
|
||||||
const parts = sentence.split(/([、,,]|[はがでをにと])/);
|
const parts = sentence.split(/([、,,]|[はがでをにと])/);
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (currentChunk.length + part.length <= maxLength) {
|
if (currentChunk.length + part.length <= maxLength) {
|
||||||
currentChunk += part;
|
currentChunk += part;
|
||||||
@ -71,11 +71,11 @@ function splitLongSentence(sentence: string, maxLength: number): string[] {
|
|||||||
currentChunk = part;
|
currentChunk = part;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentChunk.trim()) {
|
if (currentChunk.trim()) {
|
||||||
chunks.push(currentChunk.trim());
|
chunks.push(currentChunk.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still too long, force split by character limit
|
// If still too long, force split by character limit
|
||||||
const finalChunks: string[] = [];
|
const finalChunks: string[] = [];
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
@ -87,8 +87,8 @@ function splitLongSentence(sentence: string, maxLength: number): string[] {
|
|||||||
finalChunks.push(chunk);
|
finalChunks.push(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalChunks.filter(chunk => chunk.length > 0);
|
return finalChunks.filter((chunk) => chunk.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VoiceStyle {
|
interface VoiceStyle {
|
||||||
@ -112,7 +112,9 @@ async function generateAudioForChunk(
|
|||||||
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
||||||
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
||||||
|
|
||||||
console.log(`チャンク${chunkIndex + 1}の音声クエリ開始: ${itemId} (${chunkText.length}文字)`);
|
console.log(
|
||||||
|
`チャンク${chunkIndex + 1}の音声クエリ開始: ${itemId} (${chunkText.length}文字)`,
|
||||||
|
);
|
||||||
|
|
||||||
const queryResponse = await fetch(queryUrl, {
|
const queryResponse = await fetch(queryUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -157,11 +159,16 @@ async function generateAudioForChunk(
|
|||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunkWavPath = path.resolve(outputDir, `${itemId}_chunk_${chunkIndex}.wav`);
|
const chunkWavPath = path.resolve(
|
||||||
|
outputDir,
|
||||||
|
`${itemId}_chunk_${chunkIndex}.wav`,
|
||||||
|
);
|
||||||
fs.writeFileSync(chunkWavPath, audioBuffer);
|
fs.writeFileSync(chunkWavPath, audioBuffer);
|
||||||
|
|
||||||
console.log(`チャンク${chunkIndex + 1}のWAVファイル保存完了: ${chunkWavPath}`);
|
console.log(
|
||||||
|
`チャンク${chunkIndex + 1}のWAVファイル保存完了: ${chunkWavPath}`,
|
||||||
|
);
|
||||||
|
|
||||||
return chunkWavPath;
|
return chunkWavPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,25 +180,34 @@ async function concatenateAudioFiles(
|
|||||||
outputMp3Path: string,
|
outputMp3Path: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ffmpegCmd = ffmpegPath || "ffmpeg";
|
const ffmpegCmd = ffmpegPath || "ffmpeg";
|
||||||
|
|
||||||
// Create a temporary file list for FFmpeg concat
|
// Create a temporary file list for FFmpeg concat
|
||||||
const tempDir = config.paths.podcastAudioDir;
|
const tempDir = config.paths.podcastAudioDir;
|
||||||
const listFilePath = path.resolve(tempDir, `concat_list_${Date.now()}.txt`);
|
const listFilePath = path.resolve(tempDir, `concat_list_${Date.now()}.txt`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Write file list in FFmpeg concat format
|
// Write file list in FFmpeg concat format
|
||||||
const fileList = wavFiles.map(file => `file '${path.resolve(file)}'`).join('\n');
|
const fileList = wavFiles
|
||||||
|
.map((file) => `file '${path.resolve(file)}'`)
|
||||||
|
.join("\n");
|
||||||
fs.writeFileSync(listFilePath, fileList);
|
fs.writeFileSync(listFilePath, fileList);
|
||||||
|
|
||||||
console.log(`音声ファイル結合開始: ${wavFiles.length}個のファイルを結合 -> ${outputMp3Path}`);
|
console.log(
|
||||||
|
`音声ファイル結合開始: ${wavFiles.length}個のファイルを結合 -> ${outputMp3Path}`,
|
||||||
|
);
|
||||||
|
|
||||||
const result = Bun.spawnSync([
|
const result = Bun.spawnSync([
|
||||||
ffmpegCmd,
|
ffmpegCmd,
|
||||||
"-f", "concat",
|
"-f",
|
||||||
"-safe", "0",
|
"concat",
|
||||||
"-i", listFilePath,
|
"-safe",
|
||||||
"-codec:a", "libmp3lame",
|
"0",
|
||||||
"-qscale:a", "2",
|
"-i",
|
||||||
|
listFilePath,
|
||||||
|
"-codec:a",
|
||||||
|
"libmp3lame",
|
||||||
|
"-qscale:a",
|
||||||
|
"2",
|
||||||
"-y", // Overwrite output file
|
"-y", // Overwrite output file
|
||||||
outputMp3Path,
|
outputMp3Path,
|
||||||
]);
|
]);
|
||||||
@ -209,7 +225,7 @@ async function concatenateAudioFiles(
|
|||||||
if (fs.existsSync(listFilePath)) {
|
if (fs.existsSync(listFilePath)) {
|
||||||
fs.unlinkSync(listFilePath);
|
fs.unlinkSync(listFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up individual WAV files
|
// Clean up individual WAV files
|
||||||
for (const wavFile of wavFiles) {
|
for (const wavFile of wavFiles) {
|
||||||
if (fs.existsSync(wavFile)) {
|
if (fs.existsSync(wavFile)) {
|
||||||
@ -236,12 +252,14 @@ export async function generateTTSWithoutQueue(
|
|||||||
throw new Error("Script text is required for TTS generation");
|
throw new Error("Script text is required for TTS generation");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}, ${scriptText.length}文字)`);
|
console.log(
|
||||||
|
`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}, ${scriptText.length}文字)`,
|
||||||
|
);
|
||||||
|
|
||||||
// Split text into chunks
|
// Split text into chunks
|
||||||
const chunks = splitTextIntoChunks(scriptText.trim());
|
const chunks = splitTextIntoChunks(scriptText.trim());
|
||||||
console.log(`テキストを${chunks.length}個のチャンクに分割: ${itemId}`);
|
console.log(`テキストを${chunks.length}個のチャンクに分割: ${itemId}`);
|
||||||
|
|
||||||
if (chunks.length === 0) {
|
if (chunks.length === 0) {
|
||||||
throw new Error("No valid text chunks generated");
|
throw new Error("No valid text chunks generated");
|
||||||
}
|
}
|
||||||
@ -259,8 +277,10 @@ export async function generateTTSWithoutQueue(
|
|||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
if (!chunk) continue;
|
if (!chunk) continue;
|
||||||
console.log(`チャンク${i + 1}/${chunks.length}処理中: "${chunk.substring(0, 30)}${chunk.length > 30 ? '...' : ''}"`);
|
console.log(
|
||||||
|
`チャンク${i + 1}/${chunks.length}処理中: "${chunk.substring(0, 30)}${chunk.length > 30 ? "..." : ""}"`,
|
||||||
|
);
|
||||||
|
|
||||||
const wavPath = await generateAudioForChunk(chunk, i, itemId);
|
const wavPath = await generateAudioForChunk(chunk, i, itemId);
|
||||||
generatedWavFiles.push(wavPath);
|
generatedWavFiles.push(wavPath);
|
||||||
}
|
}
|
||||||
@ -273,12 +293,15 @@ export async function generateTTSWithoutQueue(
|
|||||||
if (!firstWavFile) {
|
if (!firstWavFile) {
|
||||||
throw new Error("No WAV files generated");
|
throw new Error("No WAV files generated");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = Bun.spawnSync([
|
const result = Bun.spawnSync([
|
||||||
ffmpegCmd,
|
ffmpegCmd,
|
||||||
"-i", firstWavFile,
|
"-i",
|
||||||
"-codec:a", "libmp3lame",
|
firstWavFile,
|
||||||
"-qscale:a", "2",
|
"-codec:a",
|
||||||
|
"libmp3lame",
|
||||||
|
"-qscale:a",
|
||||||
|
"2",
|
||||||
"-y",
|
"-y",
|
||||||
mp3FilePath,
|
mp3FilePath,
|
||||||
]);
|
]);
|
||||||
@ -289,7 +312,7 @@ export async function generateTTSWithoutQueue(
|
|||||||
: "Unknown error";
|
: "Unknown error";
|
||||||
throw new Error(`FFmpeg conversion failed: ${stderr}`);
|
throw new Error(`FFmpeg conversion failed: ${stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up WAV file
|
// Clean up WAV file
|
||||||
fs.unlinkSync(firstWavFile);
|
fs.unlinkSync(firstWavFile);
|
||||||
} else {
|
} else {
|
||||||
@ -299,7 +322,6 @@ export async function generateTTSWithoutQueue(
|
|||||||
|
|
||||||
console.log(`TTS生成完了: ${itemId} (${chunks.length}チャンク)`);
|
console.log(`TTS生成完了: ${itemId} (${chunks.length}チャンク)`);
|
||||||
return path.basename(mp3FilePath);
|
return path.basename(mp3FilePath);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up any generated files on error
|
// Clean up any generated files on error
|
||||||
for (const wavFile of generatedWavFiles) {
|
for (const wavFile of generatedWavFiles) {
|
||||||
@ -307,7 +329,7 @@ export async function generateTTSWithoutQueue(
|
|||||||
fs.unlinkSync(wavFile);
|
fs.unlinkSync(wavFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -318,19 +340,24 @@ export async function generateTTS(
|
|||||||
retryCount: number = 0,
|
retryCount: number = 0,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const maxRetries = 2;
|
const maxRetries = 2;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await generateTTSWithoutQueue(itemId, scriptText, retryCount);
|
return await generateTTSWithoutQueue(itemId, scriptText, retryCount);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
|
console.error(
|
||||||
|
`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
// Add to queue for retry only on initial failure
|
// Add to queue for retry only on initial failure
|
||||||
const { addToQueue } = await import("../services/database.js");
|
const { addToQueue } = await import("../services/database.js");
|
||||||
await addToQueue(itemId, scriptText, retryCount);
|
await addToQueue(itemId, scriptText, retryCount);
|
||||||
throw new Error(`TTS generation failed, added to retry queue: ${error}`);
|
throw new Error(`TTS generation failed, added to retry queue: ${error}`);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
|
throw new Error(
|
||||||
|
`TTS generation failed after ${maxRetries + 1} attempts: ${error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user