Fix episode player and episode list

This commit is contained in:
2025-06-07 14:24:33 +09:00
parent 53a408d074
commit 6830d06ed4
7 changed files with 419 additions and 53 deletions

View File

@ -6,12 +6,15 @@
"dependencies": {
"@aws-sdk/client-polly": "^3.823.0",
"@hono/node-server": "^1.14.3",
"@types/xml2js": "^0.4.14",
"ffmpeg-static": "^5.2.0",
"hono": "^4.7.11",
"openai": "^4.104.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"rss-parser": "^3.13.0",
"xml2js": "^0.6.2",
},
"devDependencies": {
"@types/bun": "latest",
@ -324,6 +327,8 @@
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
"@types/xml2js": ["@types/xml2js@0.4.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.5.1", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-uPZBqSI0YD4lpkIru6M35sIfylLGTyhGHvDZbNLuMA73lMlwJKz5xweH7FajfcCAc2HnINciejA9qTz0dr0M7A=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
@ -354,6 +359,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
@ -466,6 +473,10 @@
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-router": ["react-router@7.6.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w=="],
"react-router-dom": ["react-router-dom@7.6.2", "", { "dependencies": { "react-router": "7.6.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"rollup": ["rollup@4.41.1", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw=="],
@ -480,6 +491,8 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
@ -512,7 +525,7 @@
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@ -530,6 +543,8 @@
"openai/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
"rss-parser/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],

View File

@ -1,9 +1,19 @@
import { useState } from 'react'
import { Routes, Route, Link, useLocation } from 'react-router-dom'
import EpisodeList from './components/EpisodeList'
import FeedManager from './components/FeedManager'
import EpisodeDetail from './components/EpisodeDetail'
function App() {
const [activeTab, setActiveTab] = useState<'episodes' | 'feeds'>('episodes')
const location = useLocation()
const isEpisodeDetail = location.pathname.startsWith('/episode/')
if (isEpisodeDetail) {
return (
<Routes>
<Route path="/episode/:episodeId" element={<EpisodeDetail />} />
</Routes>
)
}
return (
<div className="container">
@ -13,23 +23,25 @@ function App() {
</div>
<div className="tabs">
<button
className={`tab ${activeTab === 'episodes' ? 'active' : ''}`}
onClick={() => setActiveTab('episodes')}
<Link
to="/"
className={`tab ${location.pathname === '/' ? 'active' : ''}`}
>
</button>
<button
className={`tab ${activeTab === 'feeds' ? 'active' : ''}`}
onClick={() => setActiveTab('feeds')}
</Link>
<Link
to="/feeds"
className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
>
</button>
</Link>
</div>
<div className="content">
{activeTab === 'episodes' && <EpisodeList />}
{activeTab === 'feeds' && <FeedManager />}
<Routes>
<Route path="/" element={<EpisodeList />} />
<Route path="/feeds" element={<FeedManager />} />
</Routes>
</div>
</div>
)

View File

@ -0,0 +1,200 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
interface Episode {
id: string
title: string
description: string
pubDate: string
audioUrl: string
audioLength: string
guid: string
link: string
}
function EpisodeDetail() {
const { episodeId } = useParams<{ episodeId: string }>()
const [episode, setEpisode] = useState<Episode | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchEpisode()
}, [episodeId])
const fetchEpisode = async () => {
if (!episodeId) return
try {
setLoading(true)
const response = await fetch(`/api/episode/${episodeId}`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
}
const data = await response.json()
setEpisode(data.episode)
} catch (err) {
console.error('Episode fetch error:', err)
setError(err instanceof Error ? err.message : 'エラーが発生しました')
} finally {
setLoading(false)
}
}
const shareEpisode = () => {
const shareUrl = window.location.href
navigator.clipboard.writeText(shareUrl).then(() => {
alert('エピソードリンクをクリップボードにコピーしました')
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
})
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP')
}
const formatFileSize = (bytes: string) => {
const size = parseInt(bytes)
if (isNaN(size)) return ''
const units = ['B', 'KB', 'MB', 'GB']
let unitIndex = 0
let fileSize = size
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024
unitIndex++
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
}
if (loading) {
return (
<div className="container">
<div className="loading">...</div>
</div>
)
}
if (error) {
return (
<div className="container">
<div className="error">{error}</div>
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
</Link>
</div>
)
}
if (!episode) {
return (
<div className="container">
<div className="error"></div>
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
</Link>
</div>
)
}
return (
<div className="container">
<div className="header">
<Link to="/" className="btn btn-secondary" style={{ marginBottom: '20px' }}>
</Link>
<div className="title" style={{ fontSize: '28px', marginBottom: '10px' }}>
{episode.title}
</div>
<div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
: {formatDate(episode.pubDate)}
</div>
</div>
<div className="content">
<div style={{ marginBottom: '30px' }}>
<audio
controls
className="audio-player"
src={episode.audioUrl}
style={{ width: '100%', height: '60px' }}
>
使
</audio>
</div>
<div style={{ display: 'flex', gap: '15px', marginBottom: '30px' }}>
<button
className="btn btn-primary"
onClick={shareEpisode}
>
</button>
{episode.link && (
<a
href={episode.link}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
>
</a>
)}
</div>
<div style={{ marginBottom: '30px' }}>
<h3 style={{ marginBottom: '15px' }}></h3>
<div style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
fontSize: '14px'
}}>
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {formatFileSize(episode.audioLength)}
</div>
{episode.guid && (
<div style={{ marginBottom: '10px' }}>
<strong>ID:</strong> {episode.guid}
</div>
)}
<div>
<strong>URL:</strong>
<a href={episode.audioUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
</a>
</div>
</div>
</div>
{episode.description && (
<div>
<h3 style={{ marginBottom: '15px' }}></h3>
<div style={{
backgroundColor: '#fff',
padding: '20px',
border: '1px solid #e9ecef',
borderRadius: '8px',
lineHeight: '1.6'
}}>
{episode.description}
</div>
</div>
)}
</div>
</div>
)
}
export default EpisodeDetail

View File

@ -3,14 +3,12 @@ import { useState, useEffect } from 'react'
interface Episode {
id: string
title: string
audioPath: string
createdAt: string
article?: {
link: string
}
feed?: {
title: string
}
description: string
pubDate: string
audioUrl: string
audioLength: string
guid: string
link: string
}
function EpisodeList() {
@ -26,14 +24,14 @@ function EpisodeList() {
const fetchEpisodes = async () => {
try {
setLoading(true)
const response = await fetch('/api/episodes')
const response = await fetch('/api/episodes-from-xml')
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
}
const data = await response.json()
console.log('Fetched episodes:', data)
setEpisodes(data)
console.log('Fetched episodes from XML:', data)
setEpisodes(data.episodes || [])
} catch (err) {
console.error('Episode fetch error:', err)
setError(err instanceof Error ? err.message : 'エラーが発生しました')
@ -46,7 +44,7 @@ function EpisodeList() {
return new Date(dateString).toLocaleString('ja-JP')
}
const playAudio = (audioPath: string) => {
const playAudio = (audioUrl: string) => {
if (currentAudio) {
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
if (currentPlayer) {
@ -54,7 +52,23 @@ function EpisodeList() {
currentPlayer.currentTime = 0
}
}
setCurrentAudio(audioPath)
setCurrentAudio(audioUrl)
}
const shareEpisode = (episode: Episode) => {
const shareUrl = `${window.location.origin}/episode/${episode.id}`
navigator.clipboard.writeText(shareUrl).then(() => {
alert('エピソードリンクをクリップボードにコピーしました')
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
})
}
if (loading) {
@ -93,10 +107,10 @@ function EpisodeList() {
<table className="table">
<thead>
<tr>
<th style={{ width: '40%' }}></th>
<th style={{ width: '20%' }}></th>
<th style={{ width: '20%' }}></th>
<th style={{ width: '20%' }}></th>
<th style={{ width: '35%' }}></th>
<th style={{ width: '25%' }}></th>
<th style={{ width: '15%' }}></th>
<th style={{ width: '25%' }}></th>
</tr>
</thead>
<tbody>
@ -106,9 +120,9 @@ function EpisodeList() {
<div style={{ marginBottom: '8px' }}>
<strong>{episode.title}</strong>
</div>
{episode.article?.link && (
{episode.link && (
<a
href={episode.article.link}
href={episode.link}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px', color: '#666' }}
@ -117,27 +131,46 @@ function EpisodeList() {
</a>
)}
</td>
<td>{episode.feed?.title || '不明'}</td>
<td>{formatDate(episode.createdAt)}</td>
<td>
<button
className="btn btn-primary"
onClick={() => playAudio(episode.audioPath)}
style={{ marginBottom: '8px' }}
>
</button>
{currentAudio === episode.audioPath && (
<div>
<audio
id={episode.audioPath}
controls
className="audio-player"
src={`/podcast_audio/${episode.audioPath}`}
onEnded={() => setCurrentAudio(null)}
/>
<div style={{
fontSize: '14px',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{episode.description || 'No description'}
</div>
</td>
<td>{formatDate(episode.pubDate)}</td>
<td>
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-primary"
onClick={() => playAudio(episode.audioUrl)}
>
</button>
<button
className="btn btn-secondary"
onClick={() => shareEpisode(episode)}
>
</button>
</div>
)}
{currentAudio === episode.audioUrl && (
<div>
<audio
id={episode.audioUrl}
controls
className="audio-player"
src={episode.audioUrl}
onEnded={() => setCurrentAudio(null)}
/>
</div>
)}
</div>
</td>
</tr>
))}

View File

@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View File

@ -12,12 +12,15 @@
"dependencies": {
"@aws-sdk/client-polly": "^3.823.0",
"@hono/node-server": "^1.14.3",
"@types/xml2js": "^0.4.14",
"ffmpeg-static": "^5.2.0",
"hono": "^4.7.11",
"openai": "^4.104.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"rss-parser": "^3.13.0"
"react-router-dom": "^7.6.2",
"rss-parser": "^3.13.0",
"xml2js": "^0.6.2"
},
"type": "module",
"devDependencies": {

100
server.ts
View File

@ -119,6 +119,106 @@ app.get("/api/episodes", async (c) => {
}
});
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.post("/api/feed-requests", async (c) => {
try {
const body = await c.req.json();