Files
VoiceRSSSummary/admin-server.ts
2025-06-08 17:03:12 +09:00

662 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Database } from "bun:sqlite";
import path from "path";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { basicAuth } from "hono/basic-auth";
import { addNewFeedUrl, batchProcess } from "./scripts/fetch_and_generate.js";
import { batchScheduler } from "./services/batch-scheduler.js";
import { config, validateConfig } from "./services/config.js";
import {
deleteFeed,
fetchAllEpisodes,
fetchEpisodesWithArticles,
getAllFeedsIncludingInactive,
getFeedById,
getFeedByUrl,
getFeedRequests,
toggleFeedActive,
updateFeedRequestStatus,
} from "./services/database.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);
}
});
// Database diagnostic endpoint
app.get("/api/admin/db-diagnostic", async (c) => {
try {
const db = new Database(config.paths.dbPath);
// 1. Check episodes table
const episodeCount = db
.prepare("SELECT COUNT(*) as count FROM episodes")
.get() as any;
// 2. Check articles table
const articleCount = db
.prepare("SELECT COUNT(*) as count FROM articles")
.get() as any;
// 3. Check feeds table
const feedCount = db
.prepare("SELECT COUNT(*) as count FROM feeds")
.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
const orphanedEpisodes = db
.prepare(`
SELECT e.id, e.title, e.article_id
FROM episodes e
LEFT JOIN articles a ON e.article_id = a.id
WHERE a.id IS NULL
`)
.all() as any[];
// 5. Check orphaned articles
const orphanedArticles = db
.prepare(`
SELECT a.id, a.title, a.feed_id
FROM articles a
LEFT JOIN feeds f ON a.feed_id = f.id
WHERE f.id IS NULL
`)
.all() as any[];
// 6. Check episodes with articles but feeds are inactive
const episodesInactiveFeeds = db
.prepare(`
SELECT e.id, e.title, f.active, f.title as feed_title
FROM episodes e
JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id
WHERE f.active = 0 OR f.active IS NULL
`)
.all() as any[];
// 7. Test the JOIN query
const joinResult = db
.prepare(`
SELECT COUNT(*) as count
FROM episodes e
JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id
WHERE f.active = 1
`)
.get() as any;
// 8. Sample feed details
const sampleFeeds = db
.prepare(`
SELECT id, title, url, active, created_at
FROM feeds
ORDER BY created_at DESC
LIMIT 5
`)
.all() as any[];
// 9. Sample episode-article-feed chain
const sampleChain = db
.prepare(`
SELECT
e.id as episode_id, e.title as episode_title,
a.id as article_id, a.title as article_title,
f.id as feed_id, f.title as feed_title, f.active
FROM episodes e
LEFT JOIN articles a ON e.article_id = a.id
LEFT JOIN feeds f ON a.feed_id = f.id
ORDER BY e.created_at DESC
LIMIT 5
`)
.all() as any[];
db.close();
const diagnosticResult = {
counts: {
episodes: episodeCount.count,
articles: articleCount.count,
feeds: feedCount.count,
activeFeeds: activeFeedCount.count,
inactiveFeeds: inactiveFeedCount.count,
},
orphaned: {
episodes: orphanedEpisodes.length,
episodeDetails: orphanedEpisodes.slice(0, 3),
articles: orphanedArticles.length,
articleDetails: orphanedArticles.slice(0, 3),
},
episodesFromInactiveFeeds: {
count: episodesInactiveFeeds.length,
details: episodesInactiveFeeds.slice(0, 3),
},
joinQuery: {
episodesWithActiveFeeds: joinResult.count,
},
samples: {
feeds: sampleFeeds,
episodeChain: sampleChain,
},
timestamp: new Date().toISOString(),
};
return c.json(diagnosticResult);
} catch (error) {
console.error("Error running database diagnostic:", error);
return c.json(
{ error: "Failed to run database diagnostic", details: error.message },
500,
);
}
});
// Feed requests management
app.get("/api/admin/feed-requests", async (c) => {
try {
const status = c.req.query("status");
const requests = await getFeedRequests(status);
return c.json(requests);
} catch (error) {
console.error("Error fetching feed requests:", error);
return c.json({ error: "Failed to fetch feed requests" }, 500);
}
});
app.patch("/api/admin/feed-requests/:id/approve", async (c) => {
try {
const requestId = c.req.param("id");
const body = await c.req.json();
const { adminNotes } = body;
// First get the request to get the URL
const requests = await getFeedRequests();
const request = requests.find((r) => r.id === requestId);
if (!request) {
return c.json({ error: "Feed request not found" }, 404);
}
if (request.status !== "pending") {
return c.json({ error: "Feed request already processed" }, 400);
}
// Add the feed
await addNewFeedUrl(request.url);
// Update request status
const updated = await updateFeedRequestStatus(
requestId,
"approved",
"admin",
adminNotes,
);
if (!updated) {
return c.json({ error: "Failed to update request status" }, 500);
}
return c.json({
success: true,
message: "Feed request approved and feed added successfully",
});
} catch (error) {
console.error("Error approving feed request:", error);
return c.json({ error: "Failed to approve feed request" }, 500);
}
});
app.patch("/api/admin/feed-requests/:id/reject", async (c) => {
try {
const requestId = c.req.param("id");
const body = await c.req.json();
const { adminNotes } = body;
const updated = await updateFeedRequestStatus(
requestId,
"rejected",
"admin",
adminNotes,
);
if (!updated) {
return c.json({ error: "Feed request not found" }, 404);
}
return c.json({
success: true,
message: "Feed request rejected successfully",
});
} catch (error) {
console.error("Error rejecting feed request:", error);
return c.json({ error: "Failed to reject feed request" }, 500);
}
});
// Batch scheduler management
app.get("/api/admin/batch/status", async (c) => {
try {
const status = batchScheduler.getStatus();
return c.json(status);
} catch (error) {
console.error("Error fetching batch status:", error);
return c.json({ error: "Failed to fetch batch status" }, 500);
}
});
app.post("/api/admin/batch/enable", async (c) => {
try {
batchScheduler.enable();
return c.json({
success: true,
message: "Batch scheduler enabled successfully",
status: batchScheduler.getStatus(),
});
} catch (error) {
console.error("Error enabling batch scheduler:", error);
return c.json({ error: "Failed to enable batch scheduler" }, 500);
}
});
app.post("/api/admin/batch/disable", async (c) => {
try {
batchScheduler.disable();
return c.json({
success: true,
message: "Batch scheduler disabled successfully",
status: batchScheduler.getStatus(),
});
} catch (error) {
console.error("Error disabling batch scheduler:", error);
return c.json({ error: "Failed to disable batch scheduler" }, 500);
}
});
// System management
app.get("/api/admin/stats", async (c) => {
try {
const feeds = await getAllFeedsIncludingInactive();
const episodes = await fetchAllEpisodes();
const feedRequests = await getFeedRequests();
const batchStatus = batchScheduler.getStatus();
const stats = {
totalFeeds: feeds.length,
activeFeeds: feeds.filter((f) => f.active).length,
inactiveFeeds: feeds.filter((f) => !f.active).length,
totalEpisodes: episodes.length,
pendingRequests: feedRequests.filter((r) => r.status === "pending")
.length,
totalRequests: feedRequests.length,
batchScheduler: {
enabled: batchStatus.enabled,
isRunning: batchStatus.isRunning,
canForceStop: batchStatus.canForceStop,
lastRun: batchStatus.lastRun,
nextRun: batchStatus.nextRun,
},
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");
// Use the batch scheduler's manual trigger method
batchScheduler.triggerManualRun().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);
}
});
app.post("/api/admin/batch/force-stop", async (c) => {
try {
console.log("🛑 Force stop batch process requested via admin panel");
const stopped = batchScheduler.forceStop();
if (stopped) {
return c.json({
result: "STOPPED",
message: "Batch process force stop signal sent",
timestamp: new Date().toISOString(),
});
} else {
return c.json(
{
result: "NO_PROCESS",
message: "No batch process is currently running",
timestamp: new Date().toISOString(),
},
200,
);
}
} catch (error) {
console.error("Error force stopping batch process:", error);
return c.json({ error: "Failed to force stop 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(`
<!DOCTYPE html>
<html>
<head>
<title>Admin Panel</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.container { max-width: 800px; margin: 0 auto; }
.error { color: #d32f2f; background: #ffebee; padding: 16px; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<h1>Admin Panel</h1>
<div class="error">
<h3>Admin UI Not Built</h3>
<p>The admin panel UI has not been built yet.</p>
<p>For now, you can use the API endpoints directly:</p>
<ul>
<li>GET /api/admin/feeds - List all feeds</li>
<li>POST /api/admin/feeds - Add new feed</li>
<li>DELETE /api/admin/feeds/:id - Delete feed</li>
<li>PATCH /api/admin/feeds/:id/toggle - Toggle feed active status</li>
<li>GET /api/admin/env - View environment variables</li>
<li>GET /api/admin/stats - View system statistics</li>
<li>POST /api/admin/batch/trigger - Trigger batch process</li>
</ul>
</div>
</div>
</body>
</html>
`);
} 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<void> {
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}`);
},
);