873 lines
28 KiB
TypeScript
873 lines
28 KiB
TypeScript
import path from "path";
|
|
import { serve } from "@hono/node-server";
|
|
import { Hono } from "hono";
|
|
import { config, validateConfig } from "./services/config.js";
|
|
import { closeBrowser } from "./services/content-extractor.js";
|
|
|
|
// Validate configuration on startup
|
|
try {
|
|
validateConfig();
|
|
console.log("Configuration validated successfully");
|
|
} catch (error) {
|
|
console.error("Configuration validation failed:", error);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Regenerate static files on startup
|
|
try {
|
|
const { regenerateStartupFiles } = await import("./services/podcast.js");
|
|
await regenerateStartupFiles();
|
|
} catch (error) {
|
|
console.error("Failed to regenerate startup files:", error);
|
|
// Don't exit - the server can still work without the regenerated files
|
|
}
|
|
|
|
const app = new Hono();
|
|
|
|
// 静的ファイルの処理
|
|
|
|
// Static file handlers
|
|
app.get("/assets/*", async (c) => {
|
|
try {
|
|
const filePath = path.join(config.paths.frontendBuildDir, 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 asset:", error);
|
|
return c.notFound();
|
|
}
|
|
});
|
|
|
|
app.get("/podcast_audio/*", async (c) => {
|
|
try {
|
|
const audioFileName = c.req.path.substring("/podcast_audio/".length);
|
|
|
|
// Basic security check
|
|
if (audioFileName.includes("..") || audioFileName.includes("/")) {
|
|
return c.notFound();
|
|
}
|
|
|
|
const audioFilePath = path.join(
|
|
config.paths.podcastAudioDir,
|
|
audioFileName,
|
|
);
|
|
const file = Bun.file(audioFilePath);
|
|
|
|
if (await file.exists()) {
|
|
const fileSize = file.size;
|
|
const range = c.req.header("range");
|
|
|
|
if (range) {
|
|
// Handle range requests for streaming
|
|
const parts = range.replace(/bytes=/, "").split("-");
|
|
const start = Number.parseInt(parts[0] || "0", 10);
|
|
const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1;
|
|
|
|
if (start >= fileSize) {
|
|
return c.text("Requested range not satisfiable", 416, {
|
|
"Content-Range": `bytes */${fileSize}`,
|
|
});
|
|
}
|
|
|
|
const chunkSize = end - start + 1;
|
|
const stream = file.stream();
|
|
|
|
return c.body(stream, 206, {
|
|
"Content-Type": "audio/mpeg",
|
|
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": chunkSize.toString(),
|
|
"Cache-Control": "public, max-age=31536000",
|
|
});
|
|
} else {
|
|
// Serve entire file with streaming support
|
|
const stream = file.stream();
|
|
return c.body(stream, 200, {
|
|
"Content-Type": "audio/mpeg",
|
|
"Content-Length": fileSize.toString(),
|
|
"Accept-Ranges": "bytes",
|
|
"Cache-Control": "public, max-age=31536000",
|
|
});
|
|
}
|
|
}
|
|
return c.notFound();
|
|
} catch (error) {
|
|
console.error("Error serving audio file:", error);
|
|
return c.notFound();
|
|
}
|
|
});
|
|
|
|
app.get("/podcast.xml", async (c) => {
|
|
try {
|
|
const filePath = path.join(config.paths.publicDir, "podcast.xml");
|
|
const file = Bun.file(filePath);
|
|
|
|
if (await file.exists()) {
|
|
const blob = await file.arrayBuffer();
|
|
return c.body(blob, 200, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
|
});
|
|
}
|
|
|
|
console.warn("podcast.xml not found");
|
|
return c.notFound();
|
|
} catch (error) {
|
|
console.error("Error serving podcast.xml:", error);
|
|
return c.notFound();
|
|
}
|
|
});
|
|
|
|
// Category-specific RSS feeds - try static file first, then generate dynamically
|
|
app.get("/podcast/category/:category", async (c) => {
|
|
try {
|
|
const category = decodeURIComponent(c.req.param("category") || "");
|
|
if (!category) {
|
|
return c.notFound();
|
|
}
|
|
|
|
// Try to serve static file first
|
|
const safeCategory = encodeURIComponent(category);
|
|
const staticFilePath = path.join(
|
|
config.paths.publicDir,
|
|
`podcast_category_${safeCategory}.xml`,
|
|
);
|
|
const staticFile = Bun.file(staticFilePath);
|
|
|
|
if (await staticFile.exists()) {
|
|
const blob = await staticFile.arrayBuffer();
|
|
return c.body(blob, 200, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
|
});
|
|
}
|
|
|
|
// Fallback to dynamic generation
|
|
console.log(
|
|
`📄 Static category RSS not found for "${category}", generating dynamically...`,
|
|
);
|
|
const { generateCategoryRSS } = await import("./services/podcast.js");
|
|
const rssXml = await generateCategoryRSS(category);
|
|
|
|
return c.body(rssXml, 200, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
`Error serving category RSS for "${c.req.param("category")}":`,
|
|
error,
|
|
);
|
|
return c.notFound();
|
|
}
|
|
});
|
|
|
|
// Feed-specific RSS feeds - try static file first, then generate dynamically
|
|
app.get("/podcast/feed/:feedId", async (c) => {
|
|
try {
|
|
const feedId = c.req.param("feedId");
|
|
if (!feedId) {
|
|
return c.notFound();
|
|
}
|
|
|
|
// Try to serve static file first
|
|
const staticFilePath = path.join(
|
|
config.paths.publicDir,
|
|
`podcast_feed_${feedId}.xml`,
|
|
);
|
|
const staticFile = Bun.file(staticFilePath);
|
|
|
|
if (await staticFile.exists()) {
|
|
const blob = await staticFile.arrayBuffer();
|
|
return c.body(blob, 200, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
|
});
|
|
}
|
|
|
|
// Fallback to dynamic generation
|
|
console.log(
|
|
`📄 Static feed RSS not found for "${feedId}", generating dynamically...`,
|
|
);
|
|
const { generateFeedRSS } = await import("./services/podcast.js");
|
|
const rssXml = await generateFeedRSS(feedId);
|
|
|
|
return c.body(rssXml, 200, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
`Error serving feed RSS for "${c.req.param("feedId")}":`,
|
|
error,
|
|
);
|
|
return c.notFound();
|
|
}
|
|
});
|
|
|
|
app.get("/default-thumbnail.svg", async (c) => {
|
|
try {
|
|
const filePath = path.join(config.paths.publicDir, "default-thumbnail.svg");
|
|
const file = Bun.file(filePath);
|
|
|
|
if (await file.exists()) {
|
|
const blob = await file.arrayBuffer();
|
|
return c.body(blob, 200, {
|
|
"Content-Type": "image/svg+xml",
|
|
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
|
});
|
|
}
|
|
|
|
console.warn("default-thumbnail.svg not found");
|
|
return c.notFound();
|
|
} catch (error) {
|
|
console.error("Error serving default-thumbnail.svg:", error);
|
|
return c.notFound();
|
|
}
|
|
});
|
|
|
|
// Frontend fallback routes
|
|
async function serveIndex(c: any) {
|
|
try {
|
|
const indexPath = path.join(config.paths.frontendBuildDir, "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" });
|
|
}
|
|
|
|
console.error(`index.html not found at ${indexPath}`);
|
|
return c.text("Frontend not built. Run 'bun run build:frontend'", 404);
|
|
} catch (error) {
|
|
console.error("Error serving index.html:", error);
|
|
return c.text("Internal server error", 500);
|
|
}
|
|
}
|
|
|
|
// Function to generate episode page with OG metadata
|
|
async function serveEpisodePage(c: any, episodeId: string) {
|
|
try {
|
|
// First, try to get episode from database
|
|
const { fetchEpisodeWithSourceInfo } = await import(
|
|
"./services/database.js"
|
|
);
|
|
let episode = null;
|
|
|
|
try {
|
|
episode = await fetchEpisodeWithSourceInfo(episodeId);
|
|
} catch (error) {
|
|
console.log("Episode not found in database, trying XML fallback");
|
|
}
|
|
|
|
// If not found in database, try XML
|
|
if (!episode) {
|
|
try {
|
|
const xml2js = await import("xml2js");
|
|
const fs = await import("fs");
|
|
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
|
|
|
|
if (fs.existsSync(podcastXmlPath)) {
|
|
const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
|
|
const parser = new xml2js.Parser();
|
|
const result = await parser.parseStringPromise(xmlContent);
|
|
|
|
const items = result?.rss?.channel?.[0]?.item || [];
|
|
const targetItem = items.find(
|
|
(item: any) => generateEpisodeId(item) === episodeId,
|
|
);
|
|
|
|
if (targetItem) {
|
|
episode = {
|
|
id: episodeId,
|
|
title: targetItem.title?.[0] || "Untitled",
|
|
description: targetItem.description?.[0] || "",
|
|
createdAt: targetItem.pubDate?.[0] || "",
|
|
audioPath: targetItem.enclosure?.[0]?.$?.url || "",
|
|
articleLink: targetItem.link?.[0] || "",
|
|
articleTitle: targetItem.title?.[0] || "Untitled",
|
|
articlePubDate: targetItem.pubDate?.[0] || "",
|
|
feedTitle: "RSS Feed",
|
|
feedUrl: "",
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error parsing XML for episode:", error);
|
|
}
|
|
}
|
|
|
|
// If still no episode found, serve regular index
|
|
if (!episode) {
|
|
return serveIndex(c);
|
|
}
|
|
|
|
// Generate the HTML with OG metadata
|
|
const indexPath = path.join(config.paths.frontendBuildDir, "index.html");
|
|
const file = Bun.file(indexPath);
|
|
|
|
if (!(await file.exists())) {
|
|
console.error(`index.html not found at ${indexPath}`);
|
|
return c.text("Frontend not built. Run 'bun run build:frontend'", 404);
|
|
}
|
|
|
|
let html = await file.text();
|
|
|
|
// Extract the base URL
|
|
const protocol = c.req.header("x-forwarded-proto") || "http";
|
|
const host = c.req.header("host") || "localhost:3000";
|
|
const baseUrl = `${protocol}://${host}`;
|
|
|
|
// Replace title and add OG metadata
|
|
const title = `${episode.title} - Voice RSS Summary`;
|
|
const description =
|
|
episode.description || `${episode.title}のエピソード詳細`;
|
|
const episodeUrl = `${baseUrl}/episode/${episodeId}`;
|
|
const imageUrl = `${baseUrl}/default-thumbnail.svg`;
|
|
const audioUrl = episode.audioPath.startsWith("/")
|
|
? `${baseUrl}${episode.audioPath}`
|
|
: `${baseUrl}/podcast_audio/${episode.audioPath}`;
|
|
|
|
// Replace the title
|
|
html = html.replace(/<title>.*?<\/title>/, `<title>${title}</title>`);
|
|
|
|
// Add OG metadata before closing </head>
|
|
const ogMetadata = `
|
|
<meta name="description" content="${description.replace(/"/g, """)}" />
|
|
|
|
<!-- OpenGraph metadata -->
|
|
<meta property="og:title" content="${episode.title.replace(/"/g, """)}" />
|
|
<meta property="og:description" content="${description.replace(/"/g, """)}" />
|
|
<meta property="og:type" content="article" />
|
|
<meta property="og:url" content="${episodeUrl}" />
|
|
<meta property="og:site_name" content="Voice RSS Summary" />
|
|
<meta property="og:locale" content="ja_JP" />
|
|
<meta property="og:image" content="${imageUrl}" />
|
|
<meta property="og:image:type" content="image/svg+xml" />
|
|
<meta property="og:image:width" content="1200" />
|
|
<meta property="og:image:height" content="630" />
|
|
<meta property="og:image:alt" content="${episode.title.replace(/"/g, """)} - Voice RSS Summary" />
|
|
<meta property="og:audio" content="${audioUrl}" />
|
|
<meta property="og:audio:type" content="audio/mpeg" />
|
|
|
|
<!-- Twitter Card metadata -->
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content="${episode.title.replace(/"/g, """)}" />
|
|
<meta name="twitter:description" content="${description.replace(/"/g, """)}" />
|
|
<meta name="twitter:image" content="${imageUrl}" />
|
|
<meta name="twitter:image:alt" content="${episode.title.replace(/"/g, """)} - Voice RSS Summary" />
|
|
|
|
<!-- Article metadata -->
|
|
<meta property="article:published_time" content="${episode.createdAt}" />
|
|
${
|
|
episode.articlePubDate && episode.articlePubDate !== episode.createdAt
|
|
? `<meta property="article:modified_time" content="${episode.articlePubDate}" />`
|
|
: ""
|
|
}
|
|
${episode.feedTitle ? `<meta property="article:section" content="${episode.feedTitle.replace(/"/g, """)}" />` : ""}
|
|
`;
|
|
|
|
html = html.replace("</head>", `${ogMetadata}</head>`);
|
|
|
|
return c.body(html, 200, { "Content-Type": "text/html; charset=utf-8" });
|
|
} catch (error) {
|
|
console.error("Error serving episode page:", error);
|
|
return serveIndex(c);
|
|
}
|
|
}
|
|
|
|
app.get("/", serveIndex);
|
|
|
|
app.get("/index.html", serveIndex);
|
|
|
|
// API endpoints for frontend
|
|
app.get("/api/episodes", async (c) => {
|
|
try {
|
|
const { fetchEpisodesWithFeedInfo } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const episodes = await fetchEpisodesWithFeedInfo();
|
|
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-from-xml", async (c) => {
|
|
try {
|
|
const xml2js = await import("xml2js");
|
|
const fs = await import("fs");
|
|
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
|
|
|
|
// Check if podcast.xml exists
|
|
if (!fs.existsSync(podcastXmlPath)) {
|
|
return c.json({ episodes: [], message: "podcast.xml not found" });
|
|
}
|
|
|
|
// Read and parse XML
|
|
const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
|
|
const parser = new xml2js.Parser();
|
|
const result = await parser.parseStringPromise(xmlContent);
|
|
|
|
const episodes = [];
|
|
const items = result?.rss?.channel?.[0]?.item || [];
|
|
|
|
for (const item of items) {
|
|
const episode = {
|
|
id: generateEpisodeId(item),
|
|
title: item.title?.[0] || "Untitled",
|
|
description: item.description?.[0] || "",
|
|
pubDate: item.pubDate?.[0] || "",
|
|
audioUrl: item.enclosure?.[0]?.$?.url || "",
|
|
audioLength: item.enclosure?.[0]?.$?.length || "0",
|
|
guid: item.guid?.[0] || "",
|
|
link: item.link?.[0] || "",
|
|
};
|
|
episodes.push(episode);
|
|
}
|
|
|
|
return c.json({ episodes });
|
|
} catch (error) {
|
|
console.error("Error parsing podcast XML:", error);
|
|
return c.json({ error: "Failed to parse podcast XML" }, 500);
|
|
}
|
|
});
|
|
|
|
// Helper function to generate episode ID from XML item
|
|
function generateEpisodeId(item: any): string {
|
|
// Use GUID if available, otherwise generate from title and audio URL
|
|
if (item.guid?.[0]) {
|
|
return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, "-"));
|
|
}
|
|
|
|
const title = item.title?.[0] || "";
|
|
const audioUrl = item.enclosure?.[0]?.$?.url || "";
|
|
const titleSlug = title
|
|
.toLowerCase()
|
|
.replace(/[^a-zA-Z0-9\s]/g, "")
|
|
.replace(/\s+/g, "-")
|
|
.substring(0, 50);
|
|
|
|
// Extract filename from audio URL as fallback
|
|
const audioFilename = audioUrl.split("/").pop()?.split(".")[0] || "episode";
|
|
|
|
return titleSlug || audioFilename;
|
|
}
|
|
|
|
app.get("/api/episode/:episodeId", async (c) => {
|
|
try {
|
|
const episodeId = c.req.param("episodeId");
|
|
const xml2js = await import("xml2js");
|
|
const fs = await import("fs");
|
|
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
|
|
|
|
if (!fs.existsSync(podcastXmlPath)) {
|
|
return c.json({ error: "podcast.xml not found" }, 404);
|
|
}
|
|
|
|
const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
|
|
const parser = new xml2js.Parser();
|
|
const result = await parser.parseStringPromise(xmlContent);
|
|
|
|
const items = result?.rss?.channel?.[0]?.item || [];
|
|
const targetItem = items.find(
|
|
(item: any) => generateEpisodeId(item) === episodeId,
|
|
);
|
|
|
|
if (!targetItem) {
|
|
return c.json({ error: "Episode not found" }, 404);
|
|
}
|
|
|
|
const episode = {
|
|
id: episodeId,
|
|
title: targetItem.title?.[0] || "Untitled",
|
|
description: targetItem.description?.[0] || "",
|
|
pubDate: targetItem.pubDate?.[0] || "",
|
|
audioUrl: targetItem.enclosure?.[0]?.$?.url || "",
|
|
audioLength: targetItem.enclosure?.[0]?.$?.length || "0",
|
|
guid: targetItem.guid?.[0] || "",
|
|
link: targetItem.link?.[0] || "",
|
|
};
|
|
|
|
return c.json({ episode });
|
|
} catch (error) {
|
|
console.error("Error fetching episode:", error);
|
|
return c.json({ error: "Failed to fetch episode" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/feeds", async (c) => {
|
|
try {
|
|
const page = c.req.query("page");
|
|
const limit = c.req.query("limit");
|
|
const category = c.req.query("category");
|
|
|
|
// If pagination parameters are provided, use paginated endpoint
|
|
if (page || limit) {
|
|
const { fetchActiveFeedsPaginated } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const pageNum = page ? Number.parseInt(page, 10) : 1;
|
|
const limitNum = limit ? Number.parseInt(limit, 10) : 10;
|
|
|
|
// Validate pagination parameters
|
|
if (Number.isNaN(pageNum) || pageNum < 1) {
|
|
return c.json({ error: "Invalid page number" }, 400);
|
|
}
|
|
if (Number.isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
|
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
|
|
}
|
|
|
|
const result = await fetchActiveFeedsPaginated(
|
|
pageNum,
|
|
limitNum,
|
|
category || undefined,
|
|
);
|
|
return c.json(result);
|
|
} else {
|
|
// Original behavior for backward compatibility
|
|
const { fetchActiveFeeds } = await import("./services/database.js");
|
|
const feeds = await fetchActiveFeeds();
|
|
return c.json({ feeds });
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching feeds:", error);
|
|
return c.json({ error: "Failed to fetch feeds" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/feeds/:feedId", async (c) => {
|
|
try {
|
|
const feedId = c.req.param("feedId");
|
|
const { getFeedById } = await import("./services/database.js");
|
|
const feed = await getFeedById(feedId);
|
|
|
|
if (!feed) {
|
|
return c.json({ error: "Feed not found" }, 404);
|
|
}
|
|
|
|
return c.json({ feed });
|
|
} catch (error) {
|
|
console.error("Error fetching feed:", error);
|
|
return c.json({ error: "Failed to fetch feed" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/feeds/:feedId/episodes", async (c) => {
|
|
try {
|
|
const feedId = c.req.param("feedId");
|
|
const { fetchEpisodesByFeedId } = await import("./services/database.js");
|
|
const episodes = await fetchEpisodesByFeedId(feedId);
|
|
return c.json({ episodes });
|
|
} catch (error) {
|
|
console.error("Error fetching episodes by feed:", error);
|
|
return c.json({ error: "Failed to fetch episodes by feed" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/categories", async (c) => {
|
|
try {
|
|
const { getAllCategories } = await import("./services/database.js");
|
|
const categories = await getAllCategories();
|
|
return c.json({ categories });
|
|
} catch (error) {
|
|
console.error("Error fetching categories:", error);
|
|
return c.json({ error: "Failed to fetch categories" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/feeds/by-category", async (c) => {
|
|
try {
|
|
const category = c.req.query("category");
|
|
const { getFeedsByCategory } = await import("./services/database.js");
|
|
const feeds = await getFeedsByCategory(category);
|
|
return c.json({ feeds });
|
|
} catch (error) {
|
|
console.error("Error fetching feeds by category:", error);
|
|
return c.json({ error: "Failed to fetch feeds by category" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/feeds/grouped-by-category", async (c) => {
|
|
try {
|
|
const { getFeedsGroupedByCategory } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const groupedFeeds = await getFeedsGroupedByCategory();
|
|
return c.json({ groupedFeeds });
|
|
} catch (error) {
|
|
console.error("Error fetching feeds grouped by category:", error);
|
|
return c.json({ error: "Failed to fetch feeds grouped by category" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/episode-with-source/:episodeId", async (c) => {
|
|
try {
|
|
const episodeId = c.req.param("episodeId");
|
|
const { fetchEpisodeWithSourceInfo } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const episode = await fetchEpisodeWithSourceInfo(episodeId);
|
|
|
|
if (!episode) {
|
|
return c.json({ error: "Episode not found" }, 404);
|
|
}
|
|
|
|
return c.json({ episode });
|
|
} catch (error) {
|
|
console.error("Error fetching episode with source info:", error);
|
|
return c.json({ error: "Failed to fetch episode with source info" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/episodes-with-feed-info", async (c) => {
|
|
try {
|
|
const page = c.req.query("page");
|
|
const limit = c.req.query("limit");
|
|
const category = c.req.query("category");
|
|
|
|
// If pagination parameters are provided, use paginated endpoint
|
|
if (page || limit) {
|
|
const { fetchEpisodesWithFeedInfoPaginated } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const pageNum = page ? Number.parseInt(page, 10) : 1;
|
|
const limitNum = limit ? Number.parseInt(limit, 10) : 20;
|
|
|
|
// Validate pagination parameters
|
|
if (Number.isNaN(pageNum) || pageNum < 1) {
|
|
return c.json({ error: "Invalid page number" }, 400);
|
|
}
|
|
if (Number.isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
|
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
|
|
}
|
|
|
|
const result = await fetchEpisodesWithFeedInfoPaginated(
|
|
pageNum,
|
|
limitNum,
|
|
category || undefined,
|
|
);
|
|
return c.json(result);
|
|
} else {
|
|
// Original behavior for backward compatibility
|
|
const { fetchEpisodesWithFeedInfo } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const episodes = await fetchEpisodesWithFeedInfo();
|
|
return c.json({ episodes });
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching episodes with feed info:", error);
|
|
return c.json({ error: "Failed to fetch episodes with feed info" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/episodes/search", async (c) => {
|
|
try {
|
|
const query = c.req.query("q");
|
|
const category = c.req.query("category");
|
|
|
|
if (!query || query.trim() === "") {
|
|
return c.json({ error: "Search query is required" }, 400);
|
|
}
|
|
|
|
const { searchEpisodesWithFeedInfo } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const episodes = await searchEpisodesWithFeedInfo(query.trim(), category);
|
|
return c.json({ episodes, query, category });
|
|
} catch (error) {
|
|
console.error("Error searching episodes:", error);
|
|
return c.json({ error: "Failed to search episodes" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/episode-with-source/:episodeId", async (c) => {
|
|
try {
|
|
const episodeId = c.req.param("episodeId");
|
|
const { fetchEpisodeWithSourceInfo } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const episode = await fetchEpisodeWithSourceInfo(episodeId);
|
|
|
|
if (!episode) {
|
|
return c.json({ error: "Episode not found" }, 404);
|
|
}
|
|
|
|
return c.json({ episode });
|
|
} catch (error) {
|
|
console.error("Error fetching episode with source info:", error);
|
|
return c.json({ error: "Failed to fetch episode with source info" }, 500);
|
|
}
|
|
});
|
|
|
|
app.post("/api/feed-requests", async (c) => {
|
|
try {
|
|
const body = await c.req.json();
|
|
const { url, requestMessage } = body;
|
|
|
|
if (!url || typeof url !== "string") {
|
|
return c.json({ error: "URL is required" }, 400);
|
|
}
|
|
|
|
const { submitFeedRequest } = await import("./services/database.js");
|
|
const requestId = await submitFeedRequest({
|
|
url,
|
|
requestMessage,
|
|
});
|
|
|
|
return c.json({
|
|
success: true,
|
|
message: "Feed request submitted successfully",
|
|
requestId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error submitting feed request:", error);
|
|
return c.json({ error: "Failed to submit feed request" }, 500);
|
|
}
|
|
});
|
|
|
|
// Episode category API endpoints
|
|
app.get("/api/episode-categories", async (c) => {
|
|
try {
|
|
const { getAllEpisodeCategories } = await import("./services/database.js");
|
|
const categories = await getAllEpisodeCategories();
|
|
return c.json({ categories });
|
|
} catch (error) {
|
|
console.error("Error fetching episode categories:", error);
|
|
return c.json({ error: "Failed to fetch episode categories" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/episodes/by-category", async (c) => {
|
|
try {
|
|
const category = c.req.query("category");
|
|
const { getEpisodesByCategory } = await import("./services/database.js");
|
|
const episodes = await getEpisodesByCategory(category);
|
|
return c.json({ episodes });
|
|
} catch (error) {
|
|
console.error("Error fetching episodes by category:", error);
|
|
return c.json({ error: "Failed to fetch episodes by category" }, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/episodes/grouped-by-category", async (c) => {
|
|
try {
|
|
const { getEpisodesGroupedByCategory } = await import(
|
|
"./services/database.js"
|
|
);
|
|
const groupedEpisodes = await getEpisodesGroupedByCategory();
|
|
return c.json({ groupedEpisodes });
|
|
} catch (error) {
|
|
console.error("Error fetching episodes grouped by category:", error);
|
|
return c.json(
|
|
{ error: "Failed to fetch episodes grouped by category" },
|
|
500,
|
|
);
|
|
}
|
|
});
|
|
|
|
app.get("/api/episode-category-stats", async (c) => {
|
|
try {
|
|
const { getEpisodeCategoryStats } = await import("./services/database.js");
|
|
const stats = await getEpisodeCategoryStats();
|
|
return c.json({ stats });
|
|
} catch (error) {
|
|
console.error("Error fetching episode category stats:", error);
|
|
return c.json({ error: "Failed to fetch episode category stats" }, 500);
|
|
}
|
|
});
|
|
|
|
// RSS endpoints information API
|
|
app.get("/api/rss-endpoints", async (c) => {
|
|
try {
|
|
const { getAllEpisodeCategories, fetchActiveFeeds } = await import(
|
|
"./services/database.js"
|
|
);
|
|
|
|
const [episodeCategories, activeFeeds] = await Promise.all([
|
|
getAllEpisodeCategories(),
|
|
fetchActiveFeeds(),
|
|
]);
|
|
|
|
const protocol = c.req.header("x-forwarded-proto") || "http";
|
|
const host = c.req.header("host") || "localhost:3000";
|
|
const baseUrl = `${protocol}://${host}`;
|
|
|
|
const endpoints = {
|
|
main: {
|
|
title: "全エピソード",
|
|
url: `${baseUrl}/podcast.xml`,
|
|
description: "すべてのエピソードを含むメインRSSフィード",
|
|
},
|
|
categories: episodeCategories.map((category) => ({
|
|
title: `カテゴリ: ${category}`,
|
|
url: `${baseUrl}/podcast/category/${encodeURIComponent(category)}`,
|
|
description: `「${category}」カテゴリのエピソードのみ`,
|
|
})),
|
|
feeds: activeFeeds.map((feed) => ({
|
|
title: `フィード: ${feed.title || feed.url}`,
|
|
url: `${baseUrl}/podcast/feed/${feed.id}`,
|
|
description: `「${feed.title || feed.url}」からのエピソードのみ`,
|
|
feedCategory: feed.category,
|
|
})),
|
|
};
|
|
|
|
return c.json({ endpoints });
|
|
} catch (error) {
|
|
console.error("Error fetching RSS endpoints:", error);
|
|
return c.json({ error: "Failed to fetch RSS endpoints" }, 500);
|
|
}
|
|
});
|
|
|
|
// Episode page with OG metadata - must be before catch-all
|
|
app.get("/episode/:episodeId", async (c) => {
|
|
const episodeId = c.req.param("episodeId");
|
|
return serveEpisodePage(c, episodeId);
|
|
});
|
|
|
|
// Catch-all for SPA routing
|
|
app.get("*", serveIndex);
|
|
|
|
// Batch processing is managed by the admin server
|
|
|
|
// Graceful shutdown
|
|
process.on("SIGINT", async () => {
|
|
console.log("\n🛑 Received SIGINT. Graceful shutdown...");
|
|
await closeBrowser();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on("SIGTERM", async () => {
|
|
console.log("\n🛑 Received SIGTERM. Graceful shutdown...");
|
|
await closeBrowser();
|
|
process.exit(0);
|
|
});
|
|
|
|
// サーバー起動
|
|
serve(
|
|
{
|
|
fetch: app.fetch,
|
|
port: 3000,
|
|
},
|
|
(info) => {
|
|
console.log(`🌟 Server is running on http://localhost:${info.port}`);
|
|
console.log(`📡 Using configuration from: ${config.paths.projectRoot}`);
|
|
console.log(`🗄️ Database: ${config.paths.dbPath}`);
|
|
console.log(
|
|
`🔄 Batch processing is managed by admin server (port ${config.admin.port})`,
|
|
);
|
|
},
|
|
);
|