mirror of
https://github.com/ershisan99/podcaster.git
synced 2025-12-16 12:33:43 +00:00
feat: render description's html
chore: refactor to get episodes data from rss feed
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"react-router-dom": "^6.22.3"
|
"react-router-dom": "^6.22.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.12",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
|
|||||||
2881
pnpm-lock.yaml
generated
2881
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { usePodcastEpisodesQuery } from "../services/podcasts/podcast.hooks";
|
import { usePodcastQuery } from "../services/podcasts/podcast.hooks";
|
||||||
|
|
||||||
export function PodcastEpisodesList() {
|
export function PodcastEpisodesList() {
|
||||||
const { podcastId } = useParams<{ podcastId: string }>();
|
const { podcastId } = useParams<{ podcastId: string }>();
|
||||||
const { data: episodesData } = usePodcastEpisodesQuery(podcastId);
|
const { data: podcast } = usePodcastQuery(podcastId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>Episodes: {episodesData?.podcast.trackCount}</div>
|
<div>Episodes: {podcast?.trackCount}</div>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -17,7 +18,7 @@ export function PodcastEpisodesList() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{episodesData?.episodes?.map((episode) => {
|
{podcast?.episodes?.map((episode) => {
|
||||||
const formattedDate = formatDate(episode.releaseDate);
|
const formattedDate = formatDate(episode.releaseDate);
|
||||||
const formattedDuration = formatDuration(episode.durationSeconds);
|
const formattedDuration = formatDuration(episode.durationSeconds);
|
||||||
const url = `/podcast/${podcastId}/episode/${episode.id}`;
|
const url = `/podcast/${podcastId}/episode/${episode.id}`;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function PodcastInfoCard({
|
|||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<h2>{author}</h2>
|
<h2>{author}</h2>
|
||||||
</Wrap>
|
</Wrap>
|
||||||
<p>{description}</p>
|
<p dangerouslySetInnerHTML={{ __html: description }}></p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePodcastEpisodesQuery } from "../services/podcasts/podcast.hooks";
|
import { usePodcastQuery } from "../services/podcasts/podcast.hooks";
|
||||||
|
|
||||||
export function Episode() {
|
export function Episode() {
|
||||||
const { podcastId, episodeId } = useParams<{
|
const { podcastId, episodeId } = useParams<{
|
||||||
@@ -7,7 +7,7 @@ export function Episode() {
|
|||||||
episodeId: string;
|
episodeId: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { data: episodesData } = usePodcastEpisodesQuery(podcastId);
|
const { data: episodesData } = usePodcastQuery(podcastId);
|
||||||
|
|
||||||
const episode = episodesData?.episodes.find(
|
const episode = episodesData?.episodes.find(
|
||||||
(episode) => episode.id.toString() === episodeId,
|
(episode) => episode.id.toString() === episodeId,
|
||||||
@@ -15,7 +15,10 @@ export function Episode() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>{episode?.description}</div>
|
<div
|
||||||
|
className={"prose"}
|
||||||
|
dangerouslySetInnerHTML={{ __html: episode?.description ?? "" }}
|
||||||
|
></div>
|
||||||
<div>
|
<div>
|
||||||
<audio controls src={episode?.audioUrl}>
|
<audio controls src={episode?.audioUrl}>
|
||||||
Audio is not supported by your browser
|
Audio is not supported by your browser
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
import {
|
import { usePodcastQuery } from "../services/podcasts/podcast.hooks";
|
||||||
usePodcastEpisodesQuery,
|
|
||||||
useTopPodcastsQuery,
|
|
||||||
} from "../services/podcasts/podcast.hooks";
|
|
||||||
import { PodcastInfoCard } from "../components/podcast-info-card";
|
import { PodcastInfoCard } from "../components/podcast-info-card";
|
||||||
|
|
||||||
export function Podcast() {
|
export function Podcast() {
|
||||||
const { podcastId } = useParams<{ podcastId: string }>();
|
const { podcastId } = useParams<{ podcastId: string }>();
|
||||||
|
|
||||||
const { data: podcasts } = useTopPodcastsQuery();
|
const { data: podcast } = usePodcastQuery(podcastId);
|
||||||
const { data: episodesData } = usePodcastEpisodesQuery(podcastId);
|
|
||||||
|
|
||||||
if (!podcastId) {
|
if (!podcastId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -17,12 +13,6 @@ export function Podcast() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!podcasts) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const podcast = podcasts.find((podcast) => podcast.id === podcastId);
|
|
||||||
|
|
||||||
if (!podcast) {
|
if (!podcast) {
|
||||||
return <h1>Podcast not found</h1>;
|
return <h1>Podcast not found</h1>;
|
||||||
}
|
}
|
||||||
@@ -33,7 +23,7 @@ export function Podcast() {
|
|||||||
author={podcast.author}
|
author={podcast.author}
|
||||||
title={podcast.title}
|
title={podcast.title}
|
||||||
description={podcast.description}
|
description={podcast.description}
|
||||||
imageURL={episodesData?.podcast.images.large ?? ""}
|
imageURL={podcast.images.large}
|
||||||
/>
|
/>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ export class PodcastExtraDTO {
|
|||||||
id: number;
|
id: number;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
images: { small: string; medium: string; large: string };
|
images: { small: string; medium: string; large: string };
|
||||||
|
author: string;
|
||||||
|
title: string;
|
||||||
|
|
||||||
constructor(data: PodcastDetails) {
|
constructor(data: PodcastDetails) {
|
||||||
this.id = data.trackId;
|
this.id = data.trackId;
|
||||||
|
this.author = data.artistName;
|
||||||
|
this.title = data.collectionName;
|
||||||
this.trackCount = data.trackCount;
|
this.trackCount = data.trackCount;
|
||||||
this.images = {
|
this.images = {
|
||||||
small: data.artworkUrl60,
|
small: data.artworkUrl60,
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export function useTopPodcastsQuery() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePodcastEpisodesQuery(podcastId?: string) {
|
export function usePodcastQuery(podcastId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [QUERY_KEYS.PODCAST_EPISODES, podcastId],
|
queryKey: [QUERY_KEYS.PODCAST_EPISODES, podcastId],
|
||||||
queryFn: () => podcastsService.getEpisodesByPodcastId(podcastId ?? ""),
|
queryFn: () => podcastsService.getPodcastById(podcastId ?? ""),
|
||||||
enabled: !!podcastId,
|
enabled: !!podcastId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GetEpisodesResponse, GetTopPodcastsResponse } from "./podcasts.types";
|
import { GetEpisodesResponse, GetTopPodcastsResponse } from "./podcasts.types";
|
||||||
import { PodcastDTO } from "./dto/podcast.dto";
|
import { PodcastDTO } from "./dto/podcast.dto";
|
||||||
import { EpisodeDto } from "./dto/episode.dto";
|
import { RssParser } from "../rss-parser";
|
||||||
import { PodcastExtraDTO } from "./dto/podcast-extra.dto";
|
import { PodcastExtraDTO } from "./dto/podcast-extra.dto";
|
||||||
|
|
||||||
class PodcastsService {
|
class PodcastsService {
|
||||||
@@ -15,32 +15,46 @@ class PodcastsService {
|
|||||||
return data.feed.entry.map((podcast) => new PodcastDTO(podcast));
|
return data.feed.entry.map((podcast) => new PodcastDTO(podcast));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEpisodesByPodcastId(podcastId: string) {
|
async getPodcastById(podcastId: string) {
|
||||||
const url = new URL(`${this.baseUrl}/lookup`);
|
try {
|
||||||
const params = {
|
const url = new URL(`${this.baseUrl}/lookup`);
|
||||||
id: podcastId,
|
const params = {
|
||||||
media: "podcast",
|
id: podcastId,
|
||||||
entity: "podcastEpisode",
|
media: "podcast",
|
||||||
limit: "20",
|
entity: "podcast",
|
||||||
};
|
limit: "1",
|
||||||
url.search = new URLSearchParams(params).toString();
|
};
|
||||||
|
|
||||||
const response: GetEpisodesResponse = await this.fetchWithoutCors(
|
url.search = new URLSearchParams(params).toString();
|
||||||
url.toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let podcast: PodcastExtraDTO = {} as PodcastExtraDTO;
|
const response: GetEpisodesResponse = await this.fetchWithoutCors(
|
||||||
const episodes: EpisodeDto[] = [];
|
url.toString(),
|
||||||
|
);
|
||||||
|
const podcastExtra = new PodcastExtraDTO(response.results[0]);
|
||||||
|
const podcastFromFeed = await this.getPodcastFeed(
|
||||||
|
response.results[0].feedUrl,
|
||||||
|
);
|
||||||
|
return { ...podcastExtra, ...podcastFromFeed };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching podcast data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response.results.forEach((entry) => {
|
async getPodcastFeed(sourceURL: string) {
|
||||||
if (entry.kind === "podcast-episode") {
|
try {
|
||||||
episodes.push(new EpisodeDto(entry));
|
// todo: only use allorigins after getting a cors error
|
||||||
} else if (entry.kind === "podcast") {
|
// const response = await fetch(
|
||||||
podcast = new PodcastExtraDTO(entry);
|
// `https://api.allorigins.win/raw?url=${encodeURIComponent(sourceURL)}`,
|
||||||
}
|
// );
|
||||||
});
|
const response = await fetch(sourceURL);
|
||||||
|
const rss = await response.text();
|
||||||
|
|
||||||
return { episodes, podcast: podcast };
|
return RssParser.parse(rss);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching and parsing data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: move into a separate service
|
//TODO: move into a separate service
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export interface Id2 {
|
|||||||
|
|
||||||
export interface GetEpisodesResponse {
|
export interface GetEpisodesResponse {
|
||||||
resultCount: number;
|
resultCount: number;
|
||||||
results: Array<Episode | PodcastDetails>;
|
results: Array<PodcastDetails>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Episode {
|
export interface Episode {
|
||||||
|
|||||||
1
src/services/rss-parser/index.ts
Normal file
1
src/services/rss-parser/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./rss-parser";
|
||||||
76
src/services/rss-parser/rss-parser.ts
Normal file
76
src/services/rss-parser/rss-parser.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
export interface Episode {
|
||||||
|
id: string;
|
||||||
|
releaseDate: string;
|
||||||
|
audioUrl: string;
|
||||||
|
title: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Podcast {
|
||||||
|
description: string;
|
||||||
|
episodes: Episode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RssParser {
|
||||||
|
public static parse(rss: string): Podcast {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(rss, "text/xml");
|
||||||
|
|
||||||
|
const channel = xmlDoc.querySelector("channel");
|
||||||
|
const description = this.getElementInnerHtml(channel, "description");
|
||||||
|
|
||||||
|
const episodes: Episode[] = [];
|
||||||
|
|
||||||
|
const items = Array.from(xmlDoc.querySelectorAll("item"));
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const id = this.getElementInnerHtml(item, "guid");
|
||||||
|
const releaseDate = this.getElementInnerHtml(item, "pubDate");
|
||||||
|
const audioUrl =
|
||||||
|
this.getElementByTagName(item, "enclosure")?.getAttribute("url") ?? "";
|
||||||
|
|
||||||
|
const episodeTitle = this.getElementInnerHtml(item, "title");
|
||||||
|
|
||||||
|
const durationSeconds = parseInt(
|
||||||
|
this.getElementInnerHtml(item, "itunes:duration") ?? "0",
|
||||||
|
);
|
||||||
|
|
||||||
|
const episodeDescription =
|
||||||
|
this.getElementInnerHtml(item, "content:encoded") ??
|
||||||
|
this.getElementInnerHtml(item, "description");
|
||||||
|
|
||||||
|
episodes.push({
|
||||||
|
id,
|
||||||
|
releaseDate,
|
||||||
|
audioUrl,
|
||||||
|
title: episodeTitle,
|
||||||
|
durationSeconds,
|
||||||
|
description: episodeDescription,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
episodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getElementByTagName(element: Element | null, tagName: string) {
|
||||||
|
return element?.getElementsByTagName(tagName)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getElementInnerHtml(element: Element | null, tagName: string) {
|
||||||
|
return this.cleanCDATA(
|
||||||
|
this.getElementByTagName(element, tagName)?.innerHTML ?? "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static cleanCDATA(data?: string) {
|
||||||
|
if (!data) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.replace("<![CDATA[", "").replace("]]>", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,5 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("@tailwindcss/typography")],
|
||||||
};
|
};
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -35,7 +35,7 @@
|
|||||||
- [x] Image and title are clickable and navigate to the podcast page
|
- [x] Image and title are clickable and navigate to the podcast page
|
||||||
- [x] Shows the episode details (title and description)
|
- [x] Shows the episode details (title and description)
|
||||||
- [x] Shows the episode audio player
|
- [x] Shows the episode audio player
|
||||||
- [ ] Renders description as HTML
|
- [x] Renders description as HTML
|
||||||
|
|
||||||
- [ ] Header
|
- [ ] Header
|
||||||
- [ ] Title is a link to the home page
|
- [ ] Title is a link to the home page
|
||||||
|
|||||||
Reference in New Issue
Block a user