diff --git a/CLAUDE.md b/CLAUDE.md
index 2bc38f6..5e33c16 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,8 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Install dependencies**: `bun install`
- **Build frontend**: `bun run build:frontend`
+- **Build admin panel**: `bun run build:admin`
- **Start server**: `bun run start` or `bun run server.ts`
+- **Start admin panel**: `bun run admin` or `bun run admin-server.ts`
- **Frontend development**: `bun run dev:frontend`
+- **Admin panel development**: `bun run dev:admin`
- **Manual batch process**: `bun run scripts/fetch_and_generate.ts`
- **Type checking**: `bunx tsc --noEmit`
@@ -17,11 +20,12 @@ This is a RSS-to-podcast automation system built with Bun runtime, Hono web fram
### Core Components
-- **server.ts**: Main Hono web server serving both API endpoints and static frontend files
+- **server.ts**: Main Hono web server serving UI and static content (port 3000)
+- **admin-server.ts**: Admin panel server for management functions (port 3001 by default)
- **scripts/fetch_and_generate.ts**: Batch processing script that fetches RSS feeds, generates summaries, and creates audio files
- **services/**: Core business logic modules:
- `config.ts`: Centralized configuration management with validation
- - `database.ts`: SQLite operations for episodes and feed tracking
+ - `database.ts`: SQLite operations for episodes and feed tracking (includes feed deletion)
- `llm.ts`: OpenAI integration for content generation and feed classification
- `tts.ts`: Text-to-speech via VOICEVOX API
- `podcast.ts`: RSS feed generation
@@ -48,16 +52,26 @@ The application uses `services/config.ts` for centralized configuration manageme
- `OPENAI_API_KEY`: OpenAI API key (required)
- `VOICEVOX_HOST`: VOICEVOX server URL (default: http://localhost:50021)
- `VOICEVOX_STYLE_ID`: Voice style ID (default: 0)
+- `ADMIN_PORT`: Admin panel port (default: 3001)
+- `ADMIN_USERNAME`: Admin panel username (optional, for basic auth)
+- `ADMIN_PASSWORD`: Admin panel password (optional, for basic auth)
- Podcast metadata and other optional settings
Configuration is validated on startup. See README.md for complete list.
### Deployment
-The application runs as a single server process on port 3000, automatically executing batch processing on startup and daily at midnight.
+The application runs as two separate servers:
+- **Main server (port 3000)**: Serves the web UI, podcast.xml, and static files
+- **Admin server (port 3001)**: Provides management interface with feed CRUD operations and environment variable management
+
+Both servers execute batch processing on startup and at regular intervals.
### Recent Improvements
+- **Admin Panel**: Separate admin server with feed management, deletion, and environment variable configuration
+- **Feed Management**: Added feed deletion functionality and active/inactive toggling
+- **API Separation**: Moved all management APIs to admin server, keeping main server focused on content delivery
- **ES Module Compatibility**: Fixed all ES module issues and removed deprecated `__dirname` usage
- **Centralized Configuration**: Added `services/config.ts` for type-safe configuration management
- **Enhanced Error Handling**: Improved error handling and validation throughout the codebase
diff --git a/admin-panel/index.html b/admin-panel/index.html
new file mode 100644
index 0000000..6d04c2d
--- /dev/null
+++ b/admin-panel/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Admin Panel - RSS Podcast Manager
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-panel/package.json b/admin-panel/package.json
new file mode 100644
index 0000000..157cb5a
--- /dev/null
+++ b/admin-panel/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "admin-panel",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@vitejs/plugin-react": "^4.0.3",
+ "typescript": "^5.0.2",
+ "vite": "^4.4.5"
+ }
+}
\ No newline at end of file
diff --git a/admin-panel/src/App.tsx b/admin-panel/src/App.tsx
new file mode 100644
index 0000000..8a8faa4
--- /dev/null
+++ b/admin-panel/src/App.tsx
@@ -0,0 +1,332 @@
+import React, { useState, useEffect } from 'react';
+
+interface Feed {
+ id: string;
+ url: string;
+ title?: string;
+ description?: string;
+ lastUpdated?: string;
+ createdAt: string;
+ active: boolean;
+}
+
+interface Stats {
+ totalFeeds: number;
+ activeFeeds: number;
+ inactiveFeeds: number;
+ totalEpisodes: number;
+ lastUpdated: string;
+ adminPort: number;
+ authEnabled: boolean;
+}
+
+interface EnvVars {
+ [key: string]: string | undefined;
+}
+
+function App() {
+ const [feeds, setFeeds] = useState([]);
+ const [stats, setStats] = useState(null);
+ const [envVars, setEnvVars] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [newFeedUrl, setNewFeedUrl] = useState('');
+ const [activeTab, setActiveTab] = useState<'dashboard' | 'feeds' | 'env'>('dashboard');
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const loadData = async () => {
+ setLoading(true);
+ try {
+ const [feedsRes, statsRes, envRes] = await Promise.all([
+ fetch('/api/admin/feeds'),
+ fetch('/api/admin/stats'),
+ fetch('/api/admin/env')
+ ]);
+
+ if (!feedsRes.ok || !statsRes.ok || !envRes.ok) {
+ throw new Error('Failed to load data');
+ }
+
+ const [feedsData, statsData, envData] = await Promise.all([
+ feedsRes.json(),
+ statsRes.json(),
+ envRes.json()
+ ]);
+
+ setFeeds(feedsData);
+ setStats(statsData);
+ setEnvVars(envData);
+ setError(null);
+ } catch (err) {
+ setError('データの読み込みに失敗しました');
+ console.error('Error loading data:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const addFeed = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newFeedUrl.trim()) return;
+
+ try {
+ const res = await fetch('/api/admin/feeds', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ feedUrl: newFeedUrl })
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ setSuccess(data.message);
+ setNewFeedUrl('');
+ loadData();
+ } else {
+ setError(data.error || 'フィード追加に失敗しました');
+ }
+ } catch (err) {
+ setError('フィード追加に失敗しました');
+ console.error('Error adding feed:', err);
+ }
+ };
+
+ const deleteFeed = async (feedId: string) => {
+ if (!confirm('本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。')) {
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/admin/feeds/${feedId}`, {
+ method: 'DELETE'
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ setSuccess(data.message);
+ loadData();
+ } else {
+ setError(data.error || 'フィード削除に失敗しました');
+ }
+ } catch (err) {
+ setError('フィード削除に失敗しました');
+ console.error('Error deleting feed:', err);
+ }
+ };
+
+ const toggleFeed = async (feedId: string, active: boolean) => {
+ try {
+ const res = await fetch(`/api/admin/feeds/${feedId}/toggle`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ active })
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ setSuccess(data.message);
+ loadData();
+ } else {
+ setError(data.error || 'フィードステータス変更に失敗しました');
+ }
+ } catch (err) {
+ setError('フィードステータス変更に失敗しました');
+ console.error('Error toggling feed:', err);
+ }
+ };
+
+ const triggerBatch = async () => {
+ try {
+ const res = await fetch('/api/admin/batch/trigger', {
+ method: 'POST'
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ setSuccess(data.message);
+ } else {
+ setError(data.error || 'バッチ処理開始に失敗しました');
+ }
+ } catch (err) {
+ setError('バッチ処理開始に失敗しました');
+ console.error('Error triggering batch:', err);
+ }
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
管理者パネル
+
RSS Podcast Manager - 管理者用インターフェース
+
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+
+
+
+ setActiveTab('dashboard')}
+ >
+ ダッシュボード
+
+ setActiveTab('feeds')}
+ >
+ フィード管理
+
+ setActiveTab('env')}
+ >
+ 環境変数
+
+
+
+
+
+ {activeTab === 'dashboard' && (
+ <>
+
+
+
{stats?.totalFeeds || 0}
+
総フィード数
+
+
+
{stats?.activeFeeds || 0}
+
アクティブフィード
+
+
+
{stats?.inactiveFeeds || 0}
+
非アクティブフィード
+
+
+
{stats?.totalEpisodes || 0}
+
総エピソード数
+
+
+
+
+
+ バッチ処理を手動実行
+
+
+ データを再読み込み
+
+
+
+
+
管理者パネルポート: {stats?.adminPort}
+
認証: {stats?.authEnabled ? '有効' : '無効'}
+
最終更新: {stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleString('ja-JP') : '不明'}
+
+ >
+ )}
+
+ {activeTab === 'feeds' && (
+ <>
+
+
+
+
フィード一覧 ({feeds.length}件)
+ {feeds.length === 0 ? (
+
+ フィードが登録されていません
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+ {activeTab === 'env' && (
+ <>
+
環境変数設定
+
+ 現在の環境変数設定を表示しています。機密情報は***SET***と表示されます。
+
+
+
+
+
+
環境変数の設定方法
+
+ 環境変数を変更するには、.envファイルを編集するか、システムの環境変数を設定してください。
+ 変更後はサーバーの再起動が必要です。
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+export default App;
\ No newline at end of file
diff --git a/admin-panel/src/index.css b/admin-panel/src/index.css
new file mode 100644
index 0000000..f5111e7
--- /dev/null
+++ b/admin-panel/src/index.css
@@ -0,0 +1,278 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-color: #f5f5f5;
+ color: #333;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.header {
+ background: #fff;
+ padding: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ margin-bottom: 20px;
+}
+
+.header h1 {
+ color: #2c3e50;
+ margin-bottom: 10px;
+}
+
+.header .subtitle {
+ color: #7f8c8d;
+ font-size: 14px;
+}
+
+.card {
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ margin-bottom: 20px;
+ overflow: hidden;
+}
+
+.card-header {
+ padding: 20px;
+ border-bottom: 1px solid #eee;
+ background: #f8f9fa;
+}
+
+.card-header h2 {
+ color: #2c3e50;
+ margin: 0;
+ font-size: 18px;
+}
+
+.card-content {
+ padding: 20px;
+}
+
+.btn {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.btn-primary {
+ background: #3498db;
+ color: white;
+}
+
+.btn-primary:hover {
+ background: #2980b9;
+}
+
+.btn-danger {
+ background: #e74c3c;
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #c0392b;
+}
+
+.btn-success {
+ background: #27ae60;
+ color: white;
+}
+
+.btn-success:hover {
+ background: #219a52;
+}
+
+.btn-warning {
+ background: #f39c12;
+ color: white;
+}
+
+.btn-warning:hover {
+ background: #e67e22;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.input {
+ padding: 8px 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 14px;
+ width: 100%;
+}
+
+.input:focus {
+ outline: none;
+ border-color: #3498db;
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 4px;
+ font-weight: 600;
+ color: #2c3e50;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.stat-card {
+ background: #fff;
+ padding: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ text-align: center;
+}
+
+.stat-card .value {
+ font-size: 32px;
+ font-weight: bold;
+ color: #3498db;
+ margin-bottom: 8px;
+}
+
+.stat-card .label {
+ color: #7f8c8d;
+ font-size: 14px;
+}
+
+.feeds-list {
+ list-style: none;
+}
+
+.feed-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px;
+ border-bottom: 1px solid #eee;
+}
+
+.feed-item:last-child {
+ border-bottom: none;
+}
+
+.feed-info h3 {
+ margin: 0 0 4px 0;
+ color: #2c3e50;
+ font-size: 16px;
+}
+
+.feed-info .url {
+ color: #7f8c8d;
+ font-size: 14px;
+ word-break: break-all;
+}
+
+.feed-info .status {
+ font-size: 12px;
+ padding: 2px 8px;
+ border-radius: 4px;
+ margin-top: 4px;
+ display: inline-block;
+}
+
+.feed-info .status.active {
+ background: #d4edda;
+ color: #155724;
+}
+
+.feed-info .status.inactive {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+.feed-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.env-list {
+ list-style: none;
+}
+
+.env-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+ border-bottom: 1px solid #eee;
+}
+
+.env-item:last-child {
+ border-bottom: none;
+}
+
+.env-key {
+ font-weight: 600;
+ color: #2c3e50;
+ min-width: 200px;
+}
+
+.env-value {
+ color: #7f8c8d;
+ font-family: monospace;
+ background: #f8f9fa;
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+
+.loading {
+ text-align: center;
+ padding: 40px;
+ color: #7f8c8d;
+}
+
+.error {
+ background: #f8d7da;
+ color: #721c24;
+ padding: 12px;
+ border-radius: 4px;
+ margin-bottom: 16px;
+}
+
+.success {
+ background: #d4edda;
+ color: #155724;
+ padding: 12px;
+ border-radius: 4px;
+ margin-bottom: 16px;
+}
+
+.add-feed-form {
+ display: flex;
+ gap: 12px;
+ align-items: end;
+}
+
+.add-feed-form .form-group {
+ flex: 1;
+ margin-bottom: 0;
+}
\ No newline at end of file
diff --git a/admin-panel/src/main.tsx b/admin-panel/src/main.tsx
new file mode 100644
index 0000000..cbe1cdf
--- /dev/null
+++ b/admin-panel/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.tsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
\ No newline at end of file
diff --git a/admin-panel/tsconfig.json b/admin-panel/tsconfig.json
new file mode 100644
index 0000000..d0104ed
--- /dev/null
+++ b/admin-panel/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
\ No newline at end of file
diff --git a/admin-panel/tsconfig.node.json b/admin-panel/tsconfig.node.json
new file mode 100644
index 0000000..099658c
--- /dev/null
+++ b/admin-panel/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
\ No newline at end of file
diff --git a/admin-panel/vite.config.ts b/admin-panel/vite.config.ts
new file mode 100644
index 0000000..08408ec
--- /dev/null
+++ b/admin-panel/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: 'dist',
+ },
+})
\ No newline at end of file
diff --git a/admin-server.ts b/admin-server.ts
new file mode 100644
index 0000000..91323d2
--- /dev/null
+++ b/admin-server.ts
@@ -0,0 +1,343 @@
+import { Hono } from "hono";
+import { serve } from "@hono/node-server";
+import { basicAuth } from "hono/basic-auth";
+import path from "path";
+import { config, validateConfig } from "./services/config.js";
+import {
+ getAllFeedsIncludingInactive,
+ deleteFeed,
+ toggleFeedActive,
+ getFeedByUrl,
+ fetchAllEpisodes,
+ fetchEpisodesWithArticles,
+} from "./services/database.js";
+import { batchProcess, addNewFeedUrl } from "./scripts/fetch_and_generate.js";
+
+// Validate configuration on startup
+try {
+ validateConfig();
+ console.log("Admin panel configuration validated successfully");
+} catch (error) {
+ console.error("Admin panel configuration validation failed:", error);
+ process.exit(1);
+}
+
+const app = new Hono();
+
+// Basic Authentication middleware (if credentials are provided)
+if (config.admin.username && config.admin.password) {
+ app.use("*", basicAuth({
+ username: config.admin.username,
+ password: config.admin.password,
+ }));
+ console.log("🔐 Admin panel authentication enabled");
+} else {
+ console.log("⚠️ Admin panel running without authentication");
+}
+
+// Environment variables management
+app.get("/api/admin/env", async (c) => {
+ try {
+ const envVars = {
+ // OpenAI Configuration
+ OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"] ? "***SET***" : undefined,
+ OPENAI_API_ENDPOINT: import.meta.env["OPENAI_API_ENDPOINT"],
+ OPENAI_MODEL_NAME: import.meta.env["OPENAI_MODEL_NAME"],
+
+ // VOICEVOX Configuration
+ VOICEVOX_HOST: import.meta.env["VOICEVOX_HOST"],
+ VOICEVOX_STYLE_ID: import.meta.env["VOICEVOX_STYLE_ID"],
+
+ // Podcast Configuration
+ PODCAST_TITLE: import.meta.env["PODCAST_TITLE"],
+ PODCAST_LINK: import.meta.env["PODCAST_LINK"],
+ PODCAST_DESCRIPTION: import.meta.env["PODCAST_DESCRIPTION"],
+ PODCAST_LANGUAGE: import.meta.env["PODCAST_LANGUAGE"],
+ PODCAST_AUTHOR: import.meta.env["PODCAST_AUTHOR"],
+ PODCAST_CATEGORIES: import.meta.env["PODCAST_CATEGORIES"],
+ PODCAST_TTL: import.meta.env["PODCAST_TTL"],
+ PODCAST_BASE_URL: import.meta.env["PODCAST_BASE_URL"],
+
+ // Admin Configuration
+ ADMIN_PORT: import.meta.env["ADMIN_PORT"],
+ ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"] ? "***SET***" : undefined,
+ ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"] ? "***SET***" : undefined,
+
+ // File Configuration
+ FEED_URLS_FILE: import.meta.env["FEED_URLS_FILE"],
+ };
+
+ return c.json(envVars);
+ } catch (error) {
+ console.error("Error fetching environment variables:", error);
+ return c.json({ error: "Failed to fetch environment variables" }, 500);
+ }
+});
+
+// Feed management API endpoints
+app.get("/api/admin/feeds", async (c) => {
+ try {
+ const feeds = await getAllFeedsIncludingInactive();
+ return c.json(feeds);
+ } catch (error) {
+ console.error("Error fetching feeds:", error);
+ return c.json({ error: "Failed to fetch feeds" }, 500);
+ }
+});
+
+app.post("/api/admin/feeds", async (c) => {
+ try {
+ const { feedUrl } = await c.req.json<{ feedUrl: string }>();
+
+ if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) {
+ return c.json({ error: "Valid feed URL is required" }, 400);
+ }
+
+ console.log("➕ Admin adding new feed URL:", feedUrl);
+
+ // Check if feed already exists
+ const existingFeed = await getFeedByUrl(feedUrl);
+ if (existingFeed) {
+ return c.json({
+ result: "EXISTS",
+ message: "Feed URL already exists",
+ feed: existingFeed
+ });
+ }
+
+ // Add new feed
+ await addNewFeedUrl(feedUrl);
+
+ return c.json({
+ result: "CREATED",
+ message: "Feed URL added successfully",
+ feedUrl
+ });
+ } catch (error) {
+ console.error("Error adding feed:", error);
+ return c.json({ error: "Failed to add feed" }, 500);
+ }
+});
+
+app.delete("/api/admin/feeds/:id", async (c) => {
+ try {
+ const feedId = c.req.param("id");
+
+ if (!feedId || feedId.trim() === "") {
+ return c.json({ error: "Feed ID is required" }, 400);
+ }
+
+ console.log("🗑️ Admin deleting feed ID:", feedId);
+
+ const deleted = await deleteFeed(feedId);
+
+ if (deleted) {
+ return c.json({
+ result: "DELETED",
+ message: "Feed deleted successfully",
+ feedId
+ });
+ } else {
+ return c.json({ error: "Feed not found" }, 404);
+ }
+ } catch (error) {
+ console.error("Error deleting feed:", error);
+ return c.json({ error: "Failed to delete feed" }, 500);
+ }
+});
+
+app.patch("/api/admin/feeds/:id/toggle", async (c) => {
+ try {
+ const feedId = c.req.param("id");
+ const { active } = await c.req.json<{ active: boolean }>();
+
+ if (!feedId || feedId.trim() === "") {
+ return c.json({ error: "Feed ID is required" }, 400);
+ }
+
+ if (typeof active !== "boolean") {
+ return c.json({ error: "Active status must be a boolean" }, 400);
+ }
+
+ console.log(`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`);
+
+ const updated = await toggleFeedActive(feedId, active);
+
+ if (updated) {
+ return c.json({
+ result: "UPDATED",
+ message: `Feed ${active ? "activated" : "deactivated"} successfully`,
+ feedId,
+ active
+ });
+ } else {
+ return c.json({ error: "Feed not found" }, 404);
+ }
+ } catch (error) {
+ console.error("Error toggling feed active status:", error);
+ return c.json({ error: "Failed to toggle feed status" }, 500);
+ }
+});
+
+// Episodes management
+app.get("/api/admin/episodes", async (c) => {
+ try {
+ const episodes = await fetchEpisodesWithArticles();
+ return c.json(episodes);
+ } catch (error) {
+ console.error("Error fetching episodes:", error);
+ return c.json({ error: "Failed to fetch episodes" }, 500);
+ }
+});
+
+app.get("/api/admin/episodes/simple", async (c) => {
+ try {
+ const episodes = await fetchAllEpisodes();
+ return c.json(episodes);
+ } catch (error) {
+ console.error("Error fetching simple episodes:", error);
+ return c.json({ error: "Failed to fetch episodes" }, 500);
+ }
+});
+
+// System management
+app.get("/api/admin/stats", async (c) => {
+ try {
+ const feeds = await getAllFeedsIncludingInactive();
+ const episodes = await fetchAllEpisodes();
+
+ const stats = {
+ totalFeeds: feeds.length,
+ activeFeeds: feeds.filter(f => f.active).length,
+ inactiveFeeds: feeds.filter(f => !f.active).length,
+ totalEpisodes: episodes.length,
+ lastUpdated: new Date().toISOString(),
+ adminPort: config.admin.port,
+ authEnabled: !!(config.admin.username && config.admin.password),
+ };
+
+ return c.json(stats);
+ } catch (error) {
+ console.error("Error fetching admin stats:", error);
+ return c.json({ error: "Failed to fetch statistics" }, 500);
+ }
+});
+
+app.post("/api/admin/batch/trigger", async (c) => {
+ try {
+ console.log("🚀 Manual batch process triggered via admin panel");
+
+ // Run batch process in background
+ runBatchProcess().catch(error => {
+ console.error("❌ Manual admin batch process failed:", error);
+ });
+
+ return c.json({
+ result: "TRIGGERED",
+ message: "Batch process started in background",
+ timestamp: new Date().toISOString()
+ });
+ } catch (error) {
+ console.error("Error triggering admin batch process:", error);
+ return c.json({ error: "Failed to trigger batch process" }, 500);
+ }
+});
+
+// Static file handlers for admin panel UI
+app.get("/assets/*", async (c) => {
+ try {
+ const filePath = path.join(config.paths.adminBuildDir, c.req.path);
+ const file = Bun.file(filePath);
+
+ if (await file.exists()) {
+ const contentType = filePath.endsWith(".js")
+ ? "application/javascript"
+ : filePath.endsWith(".css")
+ ? "text/css"
+ : "application/octet-stream";
+ const blob = await file.arrayBuffer();
+ return c.body(blob, 200, { "Content-Type": contentType });
+ }
+ return c.notFound();
+ } catch (error) {
+ console.error("Error serving admin asset:", error);
+ return c.notFound();
+ }
+});
+
+// Admin panel frontend
+async function serveAdminIndex(c: any) {
+ try {
+ const indexPath = path.join(config.paths.adminBuildDir, "index.html");
+ const file = Bun.file(indexPath);
+
+ if (await file.exists()) {
+ const blob = await file.arrayBuffer();
+ return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
+ }
+
+ // Fallback to simple HTML if admin panel is not built
+ return c.html(`
+
+
+
+ Admin Panel
+
+
+
+
+
Admin Panel
+
+
Admin UI Not Built
+
The admin panel UI has not been built yet.
+
For now, you can use the API endpoints directly:
+
+ GET /api/admin/feeds - List all feeds
+ POST /api/admin/feeds - Add new feed
+ DELETE /api/admin/feeds/:id - Delete feed
+ PATCH /api/admin/feeds/:id/toggle - Toggle feed active status
+ GET /api/admin/env - View environment variables
+ GET /api/admin/stats - View system statistics
+ POST /api/admin/batch/trigger - Trigger batch process
+
+
+
+
+
+ `);
+ } catch (error) {
+ console.error("Error serving admin index.html:", error);
+ return c.text("Internal server error", 500);
+ }
+}
+
+app.get("/", serveAdminIndex);
+app.get("/index.html", serveAdminIndex);
+app.get("*", serveAdminIndex);
+
+// Utility functions
+async function runBatchProcess(): Promise {
+ try {
+ await batchProcess();
+ } catch (error) {
+ console.error("Admin batch process failed:", error);
+ throw error;
+ }
+}
+
+// Start admin server
+serve(
+ {
+ fetch: app.fetch,
+ port: config.admin.port,
+ },
+ (info) => {
+ console.log(`🔧 Admin panel running on http://localhost:${info.port}`);
+ console.log(`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`);
+ console.log(`🗄️ Database: ${config.paths.dbPath}`);
+ },
+);
\ No newline at end of file
diff --git a/package.json b/package.json
index 67bed5b..041d041 100644
--- a/package.json
+++ b/package.json
@@ -3,8 +3,11 @@
"version": "1.0.0",
"scripts": {
"start": "bun run build:frontend && bun run server.ts",
+ "admin": "bun run build:admin && bun run admin-server.ts",
"build:frontend": "cd frontend && bun vite build",
- "dev:frontend": "cd frontend && bun vite dev"
+ "build:admin": "cd admin-panel && bun install && bun run build",
+ "dev:frontend": "cd frontend && bun vite dev",
+ "dev:admin": "cd admin-panel && bun vite dev"
},
"dependencies": {
"@aws-sdk/client-polly": "^3.823.0",
diff --git a/server.ts b/server.ts
index 335c85a..6ca135e 100644
--- a/server.ts
+++ b/server.ts
@@ -2,13 +2,7 @@ import { Hono } from "hono";
import { serve } from "@hono/node-server";
import path from "path";
import { config, validateConfig } from "./services/config.js";
-import {
- fetchAllEpisodes,
- fetchEpisodesWithArticles,
- getAllFeeds,
- getFeedByUrl
-} from "./services/database.js";
-import { batchProcess, addNewFeedUrl } from "./scripts/fetch_and_generate.js";
+import { batchProcess } from "./scripts/fetch_and_generate.js";
// Validate configuration on startup
try {
@@ -21,133 +15,6 @@ try {
const app = new Hono();
-// API routes
-app.get("/api/feeds", async (c) => {
- try {
- const feeds = await getAllFeeds();
- return c.json(feeds);
- } catch (error) {
- console.error("Error fetching feeds:", error);
- return c.json({ error: "Failed to fetch feeds" }, 500);
- }
-});
-
-app.post("/api/feeds", async (c) => {
- try {
- const { feedUrl } = await c.req.json<{ feedUrl: string }>();
-
- if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) {
- return c.json({ error: "Valid feed URL is required" }, 400);
- }
-
- console.log("➕ Adding new feed URL:", feedUrl);
-
- // Check if feed already exists
- const existingFeed = await getFeedByUrl(feedUrl);
- if (existingFeed) {
- return c.json({
- result: "EXISTS",
- message: "Feed URL already exists",
- feed: existingFeed
- });
- }
-
- // Add new feed
- await addNewFeedUrl(feedUrl);
-
- return c.json({
- result: "CREATED",
- message: "Feed URL added successfully",
- feedUrl
- });
- } catch (error) {
- console.error("Error adding feed:", error);
- return c.json({ error: "Failed to add feed" }, 500);
- }
-});
-
-app.get("/api/episodes", async (c) => {
- try {
- const episodes = await fetchEpisodesWithArticles();
- return c.json(episodes);
- } catch (error) {
- console.error("Error fetching episodes:", error);
- return c.json({ error: "Failed to fetch episodes" }, 500);
- }
-});
-
-app.get("/api/episodes/simple", async (c) => {
- try {
- const episodes = await fetchAllEpisodes();
- return c.json(episodes);
- } catch (error) {
- console.error("Error fetching simple episodes:", error);
- return c.json({ error: "Failed to fetch episodes" }, 500);
- }
-});
-
-app.post("/api/episodes/:id/regenerate", async (c) => {
- try {
- const id = c.req.param("id");
-
- if (!id || id.trim() === "") {
- return c.json({ error: "Episode ID is required" }, 400);
- }
-
- console.log("🔄 Regeneration requested for episode ID:", id);
- // TODO: Implement regeneration logic
- return c.json({
- result: "PENDING",
- episodeId: id,
- status: "pending",
- message: "Regeneration feature will be implemented in a future update"
- });
- } catch (error) {
- console.error("Error requesting regeneration:", error);
- return c.json({ error: "Failed to request regeneration" }, 500);
- }
-});
-
-// New API endpoints for enhanced functionality
-app.get("/api/stats", async (c) => {
- try {
- const feeds = await getAllFeeds();
- const episodes = await fetchAllEpisodes();
-
- const stats = {
- totalFeeds: feeds.length,
- activeFeeds: feeds.filter(f => f.active).length,
- totalEpisodes: episodes.length,
- lastUpdated: new Date().toISOString()
- };
-
- return c.json(stats);
- } catch (error) {
- console.error("Error fetching stats:", error);
- return c.json({ error: "Failed to fetch statistics" }, 500);
- }
-});
-
-app.post("/api/batch/trigger", async (c) => {
- try {
- console.log("🚀 Manual batch process triggered via API");
-
- // Run batch process in background
- runBatchProcess().catch(error => {
- console.error("❌ Manual batch process failed:", error);
- });
-
- return c.json({
- result: "TRIGGERED",
- message: "Batch process started in background",
- timestamp: new Date().toISOString()
- });
- } catch (error) {
- console.error("Error triggering batch process:", error);
- return c.json({ error: "Failed to trigger batch process" }, 500);
- }
-});
-
// 静的ファイルの処理
// Static file handlers
@@ -216,13 +83,6 @@ app.get("/podcast.xml", async (c) => {
}
});
-// Legacy endpoint - redirect to new one
-app.post("/api/add-feed", async (c) => {
- return c.json({
- error: "This endpoint is deprecated. Use POST /api/feeds instead.",
- newEndpoint: "POST /api/feeds"
- }, 410);
-});
// Frontend fallback routes
async function serveIndex(c: any) {
diff --git a/services/config.ts b/services/config.ts
index 89dbb26..21b4a40 100644
--- a/services/config.ts
+++ b/services/config.ts
@@ -26,6 +26,13 @@ interface Config {
baseUrl: string;
};
+ // Admin Panel Configuration
+ admin: {
+ port: number;
+ username?: string;
+ password?: string;
+ };
+
// File paths
paths: {
projectRoot: string;
@@ -34,6 +41,7 @@ interface Config {
publicDir: string;
podcastAudioDir: string;
frontendBuildDir: string;
+ adminBuildDir: string;
feedUrlsFile: string;
};
}
@@ -78,6 +86,12 @@ function createConfig(): Config {
baseUrl: getOptionalEnv("PODCAST_BASE_URL", "https://your-domain.com"),
},
+ admin: {
+ port: parseInt(getOptionalEnv("ADMIN_PORT", "3001")),
+ username: import.meta.env["ADMIN_USERNAME"],
+ password: import.meta.env["ADMIN_PASSWORD"],
+ },
+
paths: {
projectRoot,
dataDir,
@@ -85,6 +99,7 @@ function createConfig(): Config {
publicDir,
podcastAudioDir: path.join(publicDir, "podcast_audio"),
frontendBuildDir: path.join(projectRoot, "frontend", "dist"),
+ adminBuildDir: path.join(projectRoot, "admin-panel", "dist"),
feedUrlsFile: path.join(projectRoot, getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt")),
},
};
diff --git a/services/database.ts b/services/database.ts
index 673f5d4..e5cc400 100644
--- a/services/database.ts
+++ b/services/database.ts
@@ -183,6 +183,71 @@ export async function getAllFeeds(): Promise {
}
}
+export async function getAllFeedsIncludingInactive(): Promise {
+ try {
+ const stmt = db.prepare(
+ "SELECT * FROM feeds ORDER BY created_at DESC",
+ );
+ const rows = stmt.all() as any[];
+
+ return rows.map((row) => ({
+ id: row.id,
+ url: row.url,
+ title: row.title,
+ description: row.description,
+ lastUpdated: row.last_updated,
+ createdAt: row.created_at,
+ active: Boolean(row.active),
+ }));
+ } catch (error) {
+ console.error("Error getting all feeds including inactive:", error);
+ throw error;
+ }
+}
+
+export async function deleteFeed(feedId: string): Promise {
+ try {
+ // Start transaction
+ db.exec("BEGIN TRANSACTION");
+
+ // Delete all episodes for articles belonging to this feed
+ const deleteEpisodesStmt = db.prepare(`
+ DELETE FROM episodes
+ WHERE article_id IN (
+ SELECT id FROM articles WHERE feed_id = ?
+ )
+ `);
+ deleteEpisodesStmt.run(feedId);
+
+ // Delete all articles for this feed
+ const deleteArticlesStmt = db.prepare("DELETE FROM articles WHERE feed_id = ?");
+ deleteArticlesStmt.run(feedId);
+
+ // Delete the feed itself
+ const deleteFeedStmt = db.prepare("DELETE FROM feeds WHERE id = ?");
+ const result = deleteFeedStmt.run(feedId);
+
+ db.exec("COMMIT");
+
+ return result.changes > 0;
+ } catch (error) {
+ db.exec("ROLLBACK");
+ console.error("Error deleting feed:", error);
+ throw error;
+ }
+}
+
+export async function toggleFeedActive(feedId: string, active: boolean): Promise {
+ try {
+ const stmt = db.prepare("UPDATE feeds SET active = ? WHERE id = ?");
+ const result = stmt.run(active ? 1 : 0, feedId);
+ return result.changes > 0;
+ } catch (error) {
+ console.error("Error toggling feed active status:", error);
+ throw error;
+ }
+}
+
// Article management functions
export async function saveArticle(
article: Omit,