diff --git a/server.ts b/server.ts index 6d912cd..7f1b6d5 100644 --- a/server.ts +++ b/server.ts @@ -119,6 +119,27 @@ app.get("/podcast.xml", async (c) => { } }); +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 { @@ -138,10 +159,139 @@ async function serveIndex(c: any) { } } +// 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}` + ); + + // Add OG metadata before closing + const ogMetadata = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + ${episode.articlePubDate && episode.articlePubDate !== episode.createdAt ? + `` : ''} + ${episode.feedTitle ? `` : ''} + `; + + html = html.replace('', `${ogMetadata}`); + + 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 { @@ -376,6 +526,12 @@ app.post("/api/feed-requests", async (c) => { } }); +// 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);