Initial commit with working prototype. Successfully allows uploading to the audio books stream and creates a podcast that antennapod can load

This commit is contained in:
Justin Walrath 2025-05-11 00:37:32 -04:00
parent ae5a2a076d
commit 91fcddabb5
24 changed files with 8043 additions and 10235 deletions

1
.gitignore vendored
View File

@ -19,6 +19,7 @@
# production
/build
/public/uploads/
# misc
.DS_Store

19
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Next.js Debug with Turbopack",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"args": [
"dev",
"--turbopack"
],
"cwd": "${workspaceFolder}"
}
]
}

26
db.json Normal file
View File

@ -0,0 +1,26 @@
{
"streams": [
{
"id": "f60236c3-81dd-47ba-b0ae-afd9229ac6f2",
"title": "Audio Books",
"description": "Personal repo of audio books",
"author": "anonymous",
"language": "en",
"categories": [
"audiobooks"
]
}
],
"podcasts": [
{
"podcastId": "0be8bd5f-4a42-4a2f-9540-3a63595855fe",
"streamId": "f60236c3-81dd-47ba-b0ae-afd9229ac6f2",
"title": "House of the Spirits",
"description": "The House of the Spirits\" is Isabel Allende's debut novel, first published in 1982. It's a multi-generational family saga that blends magical realism with political history, following the Trueba family through decades of love, power, and political upheaval in an unnamed Latin American country (heavily based on Chile).",
"uploadDate": "1746935468253",
"author": "Isabel Allende",
"imageUrl": "1006e3b7-f5db-49db-bdd6-4e3967abadc2.jpg",
"url": "21f0ca38-4a3b-486b-919d-4b6c78b189c0.m4a"
}
]
}

View File

@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [new URL('https://placehold.co/**')],
}
};
export default nextConfig;

17352
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,44 @@
{
"name": "ever-givin-pod",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.3.2"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"@eslint/eslintrc": "^3"
}
"name": "ever-givin-pod",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"debug": "node --inspect-brk node_modules/next/dist/bin/next dev --turbopack",
"server": "node server/server.js",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@types/lowdb": "^1.0.15",
"formidable": "^3.5.4",
"lowdb": "^7.0.1",
"mime-types": "^3.0.1",
"multer": "^1.4.5-lts.2",
"multiparty": "^4.2.3",
"next": "15.3.2",
"podcast": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.3",
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/express": "^5.0.1",
"@types/formidable": "^3.4.5",
"@types/mime-types": "^2.1.4",
"@types/multer": "^1.4.12",
"@types/multiparty": "^4.2.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -0,0 +1,18 @@
import { useRouter } from 'next/navigation';
export default function AddPodcastCard({ stream }: { stream: string }) {
const router = useRouter();
const handleClick = () => {
router.push(`${process.env.NEXT_PUBLIC_API_BASE_URL}/${stream}/publish`);
};
return (
<div
onClick={handleClick}
className="w-[150px] h-[150px] shadow-md rounded-lg overflow-hidden flex items-center justify-center m-4 mb-1 bg-purple-200 cursor-pointer hover:bg-purple-300 transition-colors"
>
<span className="text-4xl font-bold text-gray-800">+</span>
</div>
);
}

View File

@ -0,0 +1,17 @@
"use client";
import { useParams } from 'next/navigation';
import Main from '../../layout/main';
import PodcastList from './podcastList';
import PodcastSummary from './podcastSummary';
export default function StreamPodcasts() {
const params = useParams();
return (
<Main>
<PodcastSummary stream={params?.stream} />
<PodcastList stream={params?.stream} />
</Main>
);
}

View File

@ -0,0 +1,31 @@
import Image from 'next/image';
import { PodcastDto } from '../common/dtos/podcastDto';
export default function PodcastCard({
imageUrl = "https://placehold.co/400",
title,
description,
uploadDate,
url,
author,
}: PodcastDto) {
return (
<div className="w-full h-[150px] shadow-md rounded-sm overflow-hidden flex m-4 mb-1">
<div className="w-[150px] h-full bg-purple-100 flex-shrink-0 relative">
<Image unoptimized src={imageUrl} alt={title} layout="fill" objectFit="cover" />
</div>
<div className="p-4 flex-grow flex flex-col bg-purple-200">
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
<p className="text-sm text-gray-600 flex-grow">{description}</p>
<div className="mt-2 text-sm text-gray-500">
<p>By: {author}</p>
<p>Uploaded: {uploadDate}</p>
</div>
<a href={url} target="_blank" rel="noopener noreferrer" className="mt-4 text-blue-500 hover:underline text-sm">
Listen Now
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import PodcastCard from './podcastCard';
import { useEffect, useState } from 'react';
import { PodcastDto } from '../../../common/dtos/podcastDto';
import AddPodcastCard from './addPodcastCard';
type PodcastListProps = {
stream: string;
}
export default function PodcastList({ stream }: PodcastListProps) {
const [podcastData, setPodcastData] = useState<PodcastDto[]>([]);
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/${stream}/podcasts`)
.then((res) => res.json())
.then((data) => setPodcastData(data));
}, []);
return (
<div className="max-w-[800px] mx-auto w-full px-4">
<AddPodcastCard stream={stream}/>
{
podcastData.map((podcast, index) => (
<div key={index} className="flex justify-center">
<PodcastCard
title={podcast.title}
description={podcast.description}
uploadDate={podcast.uploadDate}
url={podcast.url}
author={podcast.author}
/>
</div>
))
}
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import Image from 'next/image';
import { StreamDto } from '../common/dtos/steamDto';
import { useEffect, useState } from 'react';
type PodcastSummaryType = {
stream: string;
};
export default function PodcastSummary({ stream }: PodcastSummaryType) {
const [summaryData, setSummaryData] = useState<StreamDto>(null);
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/${stream}`)
.then((res) => res.json())
.then((data) => setSummaryData(data));
}, []);
if (!summaryData) {
return <p>Loading...</p>;
}
return (
<div className="w-full h-auto shadow-md overflow-hidden flex flex-col bg-purple-200">
<div className="flex flex-col md:flex-row md:items-start">
<div className="relative w-full h-[150px] md:w-1/3 md:h-[376] bg-purple-100 flex-shrink-0">
<Image unoptimized src={summaryData.imageUrl} alt={summaryData.title} fill className="rounded-md object-cover" />
</div>
<div className="p-4 flex-grow flex flex-col md:ml-4">
<h2 className="text-lg font-bold text-gray-800">{summaryData.title}</h2>
<p className="text-sm text-gray-600">{summaryData.description}</p>
<div className="mt-4 text-sm text-gray-500">
<p><strong>Author:</strong> {summaryData.author}</p>
<p><strong>Managing Editor:</strong> {summaryData.managingEditor}</p>
<p><strong>Web Master:</strong> {summaryData.webMaster}</p>
<p><strong>Language:</strong> {summaryData.language}</p>
<p><strong>Published Date:</strong> {summaryData.pubDate}</p>
<p><strong>TTL:</strong> {summaryData.ttl} minutes</p>
</div>
<div className="mt-4">
<p className="text-sm font-bold text-gray-700">Categories:</p>
<ul className="list-disc list-inside text-sm text-gray-600">
{summaryData.categories?.map((category, index) => (
<li key={index}>{category}</li>
))}
</ul>
</div>
<div className="mt-4">
<a
href={summaryData.feedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline text-sm block"
>
View Feed
</a>
<a
href={summaryData.siteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline text-sm block mt-2"
>
Visit Site
</a>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
"use client";
import { useParams } from 'next/navigation';
import Main from '../../layout/main';
import StreamPublishForm from './publishForm';
export default function StreamPublish() {
const params = useParams();
return (
<Main>
<StreamPublishForm stream={params?.stream} />
</Main>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { useForm, SubmitHandler } from "react-hook-form";
type PodcastDto = {
imageUrl?: FileList;
title: string;
description: string;
uploadDate: string;
url: FileList;
author: string;
};
export default function StreamPublishForm({ stream }: { stream?: string }) {
const { register, handleSubmit, formState: { errors } } = useForm<PodcastDto>();
const onSubmit: SubmitHandler<PodcastDto> = (data) => {
const formData = new FormData();
formData.append("image", data.imageUrl?.[0] as File);
formData.append("title", data.title);
formData.append("description", data.description);
formData.append("uploadDate", data.uploadDate);
formData.append("file", data.url?.[0] as File);
formData.append("author", data.author);
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/${stream}/podcasts`, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((result) => {
console.log("Podcast created:", result);
})
.catch((err) => console.error("Error creating podcast:", err));
};
return (
<div className="max-w-[800px] mx-auto w-full px-4 bg-purple-200 rounded-sm pt-4 pb-4 mt-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
Title
</label>
<input
id="title"
{...register("title", { required: "Title is required" })}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 sm:text-sm p-3"
/>
{errors.title && <p className="text-red-500 text-sm">{errors.title.message}</p>}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
{...register("description", { required: "Description is required" })}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 sm:text-sm p-3"
/>
{errors.description && <p className="text-red-500 text-sm">{errors.description.message}</p>}
</div>
<div>
<label htmlFor="author" className="block text-sm font-medium text-gray-700">
Author
</label>
<input
id="author"
{...register("author", { required: "Author is required" })}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 sm:text-sm p-3"
/>
{errors.author && <p className="text-red-500 text-sm">{errors.author.message}</p>}
</div>
<div>
<label htmlFor="imageFile" className="block text-sm font-medium text-gray-700">
Upload Image
</label>
<input
id="imageFile"
type="file"
accept="image/*"
{...register("imageUrl", { required: "Image is required" })}
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border file:border-gray-300 file:text-sm file:font-semibold file:bg-gray-50 hover:file:bg-gray-100"
/>
{errors.imageUrl && <p className="text-red-500 text-sm">{errors.imageUrl.message}</p>}
</div>
<div>
<label htmlFor="podcastFile" className="block text-sm font-medium text-gray-700">
Upload Podcast File
</label>
<input
id="podcastFile"
type="file"
accept="audio/*"
{...register("url", { required: "Podcast file is required" })}
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border file:border-gray-300 file:text-sm file:font-semibold file:bg-gray-50 hover:file:bg-gray-100"
/>
{errors.url && <p className="text-red-500 text-sm">{errors.url.message}</p>}
</div>
<button
type="submit"
className="w-full bg-purple-600 text-purple-200 py-2 px-4 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
>
Submit
</button>
</form>
</div>
);
}

View File

@ -1,26 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
:root {
--background: #09001e;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
color: #09001e;
}

View File

@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Ever Givin Pod",
description: "Basic podcast management app",
};
export default function RootLayout({
@ -24,9 +24,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>

26
src/app/layout/main.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
type MainProps = {
children: React.ReactNode;
};
export default function Main({ children }: MainProps) {
const router = useRouter();
const handleHeaderClick = () => {
router.push(`${process.env.NEXT_PUBLIC_API_BASE_URL}`);
};
return (
<div className="min-h-screen flex flex-col">
<header className="bg-purple-300 py-4 sticky top-0 z-10">
<h1 className="text-center text-2xl font-bold cursor-pointer" onClick={handleHeaderClick}>Ever Givin Pod</h1>
</header>
<main className="flex-grow">
{children}
</main>
</div>
)
}

View File

@ -1,103 +1,10 @@
import Image from "next/image";
import Main from './layout/main';
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
<Main>
<h1 className='text-purple-200'>Welcome</h1>
<a className='text-purple-200' href={`${process.env.NEXT_PUBLIC_API_BASE_URL}/f60236c3-81dd-47ba-b0ae-afd9229ac6f2/podcasts`}>Go to podcast list.</a>
</Main>
);
}

78
src/common/data/db.ts Normal file
View File

@ -0,0 +1,78 @@
import path from 'path';
import { JSONFilePreset } from 'lowdb/node';
import { PodcastDto } from '../dtos/podcastDto';
import { StreamDto } from '../dtos/streamDto';
export type DatabaseSchema = {
streams: StreamDto[];
podcasts: PodcastDto[];
};
const defaultData: DatabaseSchema = {
streams: [
{
id: 'f60236c3-81dd-47ba-b0ae-afd9229ac6f2',
title: 'Audio Books',
description: 'Personal repo of audio books',
author: 'anonymous',
language: 'en',
categories: ['audiobooks'],
},
],
podcasts: [],
};
const db = await JSONFilePreset(path.join(process.cwd(), 'db.json'), defaultData)
const writeQueue: (() => Promise<void>)[] = [];
let isWriting = false;
const processQueue = async () => {
if (isWriting || writeQueue.length === 0) return;
isWriting = true;
const task = writeQueue.shift();
if (task) {
await task();
}
isWriting = false;
processQueue();
};
const queueWrite = async (task: () => Promise<void>) => {
writeQueue.push(task);
processQueue();
};
export const initDb = async () => {
await db.read();
db.data ??= defaultData;
await db.write();
};
await queueWrite(async () => {
await initDb();
});
// ----------------------
// ----- Operations -----
// ----------------------
export const getPodcasts = async (stream: string) => {
return db.data?.podcasts.filter((podcast) => podcast.streamId === stream) ?? [];
};
export const publishPodcast = async (podcast: PodcastDto) => {
await queueWrite(async () => {
db.data?.podcasts.push(podcast);
await db.write();
});
};
export const getStreams = async () => {
return db.data?.streams ?? [];
};
export const getStream = async (id: string) => {
return db.data?.streams.find((stream) => stream.id === id);
};

View File

@ -0,0 +1,10 @@
export type PodcastDto = {
podcastId: string;
streamId: string;
imageUrl?: string;
title?: string;
description?: string;
uploadDate: string;
url?: string;
author?: string;
};

View File

@ -0,0 +1,15 @@
export type StreamDto = {
id: string;
title?: string;
description?: string;
feedUrl?: string;
siteUrl?: string;
imageUrl?: string;
author?: string;
managingEditor?: string;
webMaster?: string;
language?: string;
categories?: string[];
pubDate?: string;
ttl?: number;
};

View File

@ -0,0 +1,53 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import mime from 'mime-types';
import { PodcastDto } from '../dtos/podcastDto';
export const getFileSize = (filePath: string): number => {
const stats = fs.statSync(filePath);
return stats.size;
};
export const getFileMimeType = (filePath: string): string => {
const ext = path.extname(filePath);
const mimeType = mime.lookup(ext);
return mimeType || 'application/octet-stream';
};
export const convertUrlToPublic = (url?: string) => {
return url ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/uploads/${url}` : undefined;
};
export const preparePodcastItem = (podcast: PodcastDto) => {
const fileSize = getFileSize(path.join(process.cwd(), `public/uploads/${podcast.url}`)),
fileUrl = convertUrlToPublic(podcast.url);
return {
...podcast,
imageUrl: convertUrlToPublic(podcast.imageUrl),
url: fileUrl,
enclosure: {
url: fileUrl,
type: getFileMimeType(podcast.url ?? ''),
size: fileSize
}
}
}
export const getLocalIpAddress = (): string => {
const networkInterfaces = os.networkInterfaces();
for (const interfaceName in networkInterfaces) {
const interfaces = networkInterfaces[interfaceName];
if (!interfaces) continue;
for (const iface of interfaces) {
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return 'localhost';
};

35
src/pages/api/[stream].ts Normal file
View File

@ -0,0 +1,35 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { StreamDto } from '../../common/dtos/streamDto';
import { getStream } from '../../common/data/db';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<StreamDto | undefined>
) {
const { stream } = req.query;
const data = await getStream(stream as string);
res.status(200).json(data)
// if(stream == 'nothing') {
// res.status(200).json(null);
// } else {
// res.status(200).json(
// {
// title: "Stream: stream1",
// description: "This is the description for stream: stream1",
// feedUrl: "http://localhost:3000/api/stream1/feed",
// siteUrl: "http://localhost:3000/stream1/podcasts",
// imageUrl: "https://placehold.co/400",
// author: "John Doe",
// managingEditor: "Jane Doe",
// webMaster: "John Smith",
// language: "en",
// categories: ["Technology", "Education", "Entertainment"],
// pubDate: "2023-10-01",
// ttl: 60,
// }
// );
// }
}

View File

@ -0,0 +1,18 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { Podcast } from 'podcast';
import { getPodcasts, getStream } from '../../../common/data/db';
import { preparePodcastItem } from '../../../common/helpers/data';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { stream } = req.query,
data = await getStream(stream as string),
items = (await getPodcasts(stream as string)).map(preparePodcastItem),
feed = new Podcast(data);
items.forEach(item => feed.addItem(item));
res.setHeader('Content-Type', 'application/xml');
res.status(200).send(feed.buildXml());
}

View File

@ -0,0 +1,127 @@
import { NextApiRequest, NextApiResponse } from 'next';
import fs from 'fs/promises';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import multiparty from 'multiparty';
import { getPodcasts, publishPodcast } from '../../../common/data/db';
import { PodcastDto } from '../../../common/dtos/podcastDto';
const get = async (req: NextApiRequest, res: NextApiResponse<PodcastDto[]>) => {
const stream = req.query.stream as string,
podcasts = await getPodcasts(stream);
res.status(200).json(podcasts);
};
export const config = {
api: {
bodyParser: false
},
};
const post = async (req: NextApiRequest, res: NextApiResponse) => {
const stream = req.query.stream as string;
if (!stream || typeof stream !== 'string') {
return res.status(400).json({ error: 'Invalid stream parameter' });
}
const uploadDir = path.join(process.cwd(), 'public/uploads');
await fs.mkdir(uploadDir, { recursive: true });
const form = new multiparty.Form({
uploadDir
});
form.parse(req, async (err, fields, files) => {
if (err) {
console.error('Error parsing form:', err);
return res.status(500).json({ error: 'Error processing form data' });
}
try {
const title = fields.title?.[0];
const description = fields.description?.[0];
const author = fields.author?.[0];
console.log(fields);
const imageFile = files.image?.[0];
const podcastFile = files.file?.[0];
if (!imageFile || !podcastFile) {
return res.status(400).json({ error: 'Missing image or file' });
}
const imageFileName = `${uuidv4()}${path.extname(imageFile.originalFilename)}`;
const podcastFileName = `${uuidv4()}${path.extname(podcastFile.originalFilename)}`;
const imageFilePath = path.join(uploadDir, imageFileName);
const podcastFilePath = path.join(uploadDir, podcastFileName);
await fs.rename(imageFile.path, imageFilePath);
await fs.rename(podcastFile.path, podcastFilePath);
const podcastData: PodcastDto = {
podcastId: uuidv4(),
streamId: stream,
title,
description,
uploadDate: Date.now().toString(),
author,
imageUrl: imageFileName,
url: podcastFileName,
};
await publishPodcast(podcastData);
res.status(201).json({ message: 'Podcast created successfully' });
} catch (error) {
console.error('Error handling request:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// try {
// const { title, description, author, image, file } = req.body;
// const uploadDir = path.join(process.cwd(), 'public/uploads');
// const imageFileName = `${uuidv4()}${path.extname(image.name)}`;
// const imageFilePath = path.join(uploadDir, imageFileName);
// await fs.writeFile(imageFilePath, Buffer.from(image.data, 'base64'));
// const podcastFileName = `${uuidv4()}${path.extname(file.name)}`;
// const podcastFilePath = path.join(uploadDir, podcastFileName);
// await fs.writeFile(podcastFilePath, Buffer.from(file.data, 'base64'));
// const podcastData: PodcastDto = {
// podcastId: uuidv4(),
// streamId: stream,
// title,
// description,
// uploadDate: Date.now().toString(),
// author,
// imageUrl: imageFileName,
// url: podcastFileName,
// };
// await publishPodcast(podcastData);
// res.status(201).json({ message: 'Podcast created successfully' });
// } catch (error) {
// console.error('Error handling request:', error);
// res.status(500).json({ error: 'Internal server error' });
// }
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'GET') {
get(req, res);
} else if (req.method === 'POST') {
post(req, res);
} else {
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}