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}
} + +
+
+ +
+ +
+ {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' && ( + <> +
+
+ + setNewFeedUrl(e.target.value)} + placeholder="https://example.com/feed.xml" + required + /> +
+ +
+ +
+

フィード一覧 ({feeds.length}件)

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

+ フィードが登録されていません +

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

    {feed.title || 'タイトル未設定'}

    +
    {feed.url}
    + + {feed.active ? 'アクティブ' : '非アクティブ'} + +
    +
    + + +
    +
  • + ))} +
+ )} +
+ + )} + + {activeTab === 'env' && ( + <> +

環境変数設定

+

+ 現在の環境変数設定を表示しています。機密情報は***SET***と表示されます。 +

+ +
    + {Object.entries(envVars).map(([key, value]) => ( +
  • +
    {key}
    +
    + {value === undefined ? '未設定' : value} +
    +
  • + ))} +
+ +
+

環境変数の設定方法

+

+ 環境変数を変更するには、.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,